From 4dfa28aac892861235297b1436db15dabb468ed3 Mon Sep 17 00:00:00 2001 From: corticalstack Date: Sat, 30 May 2026 15:27:31 +0200 Subject: [PATCH] feat(08-agents): add 08-09 REST invocation and 08-10 hosted Copilot SDK agent labs - 08-09-invoke-agent-via-rest: single-shot, multi-turn, and streaming notebooks that invoke a Foundry agent over raw REST (responses API). - 08-10-hosted-copilot-sdk-agent: deploy a GitHub Copilot SDK agent as a Foundry hosted agent (Bicep + az acr build + create_version). BYOK Foundry model via Managed Identity over the project endpoint, an M365 license analytics demo with rendered tables and an agent-generated, downloadable cost-by-department chart, OTel tracing, and a documented agentic-loop and permission model. - Remove the accidentally-committed upstream .git directory and azd scaffolding from the old 08-agents/08-09 path. - pyproject: bump azure-ai-projects, drop the unused azure-ai-inference from the finetune dependency group. --- .../08-09-00-invoke-agent-via-rest.md | 185 ++ .../08-09-01-rest-single-shot.ipynb | 218 ++ .../08-09-02-rest-multi-turn.ipynb | 234 ++ .../08-09-03-rest-streaming.ipynb | 249 ++ 08-agents/08-09/Dockerfile | 1 - 08-agents/08-09/HEAD | 1 - 08-agents/08-09/HEAD (10) | 8 - 08-agents/08-09/HEAD (9) | 42 - 08-agents/08-09/abbreviations.json | 13 - 08-agents/08-09/acr-role-assignment.bicep | 1 - 08-agents/08-09/agent.yaml | 1 - 08-agents/08-09/applicationinsights.bicep | 78 - 08-agents/08-09/applypatch-msg.sample | Bin 4306 -> 0 bytes 08-agents/08-09/azure_ai_search.bicep | Bin 276 -> 0 bytes 08-agents/08-09/bing_custom_grounding.bicep | Bin 33305 -> 0 bytes 08-agents/08-09/bing_grounding.bicep | Bin 2640 -> 0 bytes 08-agents/08-09/config | 0 08-agents/08-09/config (2).json | 1 - 08-agents/08-09/config (5).json | 1 - 08-agents/08-09/description | 37 - 08-agents/08-09/download (1) | 1 - 08-agents/08-09/download (12) | 72 - 08-agents/08-09/download (3) | 2 - 08-agents/08-09/download (6) | 37 - 08-agents/08-09/env (4).lock | 0 08-agents/08-09/exclude | 1 - 08-agents/08-09/fsmonitor-watchman.sample | 2 - 08-agents/08-09/loganalytics.bicep | 128 - 08-agents/08-09/main | 174 -- 08-agents/08-09/main (8) | 24 - 08-agents/08-09/main.bicep | 53 - 08-agents/08-09/main.parameters.json | 14 - ...c677fc7b0621d00ee9b2e0ab1512c230d76589.idx | 49 - ...677fc7b0621d00ee9b2e0ab1512c230d76589.pack | 77 - ...c677fc7b0621d00ee9b2e0ab1512c230d76589.rev | 15 - 08-agents/08-09/packed-refs | 14 - 08-agents/08-09/pre-applypatch.sample | 1 - 08-agents/08-09/pre-merge-commit.sample | 6 - 08-agents/08-09/pre-rebase.sample | 1 - 08-agents/08-09/pre-receive.sample | 1 - 08-agents/08-09/push-to-checkout.sample | 169 -- 08-agents/08-09/tmp.txt | 1 - 08-agents/08-09/tracing.py | 1 - 08-agents/08-09/update.sample | 24 - .../08-10-hosted-copilot-sdk-agent/.gitignore | 13 + .../08-10-00-hosted-copilot-sdk-agent.md | 160 ++ ...0-01-deploy-hosted-copilot-sdk-agent.ipynb | 1110 ++++++++ .../data/m365-licenses.csv | 101 + .../data/m365-reference.json | 20 + .../infra/abbreviations.json} | 274 +- .../infra/core/ai/acr-role-assignment.bicep | 27 + .../infra/core/ai/ai-project.bicep | 413 +++ .../infra/core/ai/connection.bicep | 112 + .../infra/core/ai/existing-ai-project.bicep | 70 + .../infra/core/host/acr.bicep | 88 + .../applicationinsights-dashboard.bicep} | 2472 ++++++++--------- .../core/monitor/applicationinsights.bicep} | 94 +- .../infra/core/monitor/loganalytics.bicep} | 44 +- .../infra/core/search/azure_ai_search.bicep | 211 ++ .../core/search/bing_custom_grounding.bicep | 84 + .../infra/core/search/bing_grounding.bicep | 83 + .../infra/core/storage/storage.bicep | 113 + .../infra/main.bicep} | 474 ++-- .../github-copilot-invocations/.dockerignore | 26 + .../src/github-copilot-invocations/Dockerfile | 25 + .../src/github-copilot-invocations/README.md | 150 + .../src/github-copilot-invocations/main.py | 230 ++ .../requirements.txt | 12 + .../skills/m365-license-analytics/SKILL.md | 44 + .../system_prompt.md | 9 + .../src/github-copilot-invocations/tracing.py | 204 ++ pyproject.toml | 3 +- 72 files changed, 5871 insertions(+), 2732 deletions(-) create mode 100644 08-agents/08-09-invoke-agent-via-rest/08-09-00-invoke-agent-via-rest.md create mode 100644 08-agents/08-09-invoke-agent-via-rest/08-09-01-rest-single-shot.ipynb create mode 100644 08-agents/08-09-invoke-agent-via-rest/08-09-02-rest-multi-turn.ipynb create mode 100644 08-agents/08-09-invoke-agent-via-rest/08-09-03-rest-streaming.ipynb delete mode 100644 08-agents/08-09/Dockerfile delete mode 100644 08-agents/08-09/HEAD delete mode 100644 08-agents/08-09/HEAD (10) delete mode 100644 08-agents/08-09/HEAD (9) delete mode 100644 08-agents/08-09/abbreviations.json delete mode 100644 08-agents/08-09/acr-role-assignment.bicep delete mode 100644 08-agents/08-09/agent.yaml delete mode 100644 08-agents/08-09/applicationinsights.bicep delete mode 100644 08-agents/08-09/applypatch-msg.sample delete mode 100644 08-agents/08-09/azure_ai_search.bicep delete mode 100644 08-agents/08-09/bing_custom_grounding.bicep delete mode 100644 08-agents/08-09/bing_grounding.bicep delete mode 100644 08-agents/08-09/config delete mode 100644 08-agents/08-09/config (2).json delete mode 100644 08-agents/08-09/config (5).json delete mode 100644 08-agents/08-09/description delete mode 100644 08-agents/08-09/download (1) delete mode 100644 08-agents/08-09/download (12) delete mode 100644 08-agents/08-09/download (3) delete mode 100644 08-agents/08-09/download (6) delete mode 100644 08-agents/08-09/env (4).lock delete mode 100644 08-agents/08-09/exclude delete mode 100644 08-agents/08-09/fsmonitor-watchman.sample delete mode 100644 08-agents/08-09/loganalytics.bicep delete mode 100644 08-agents/08-09/main delete mode 100644 08-agents/08-09/main (8) delete mode 100644 08-agents/08-09/main.bicep delete mode 100644 08-agents/08-09/main.parameters.json delete mode 100644 08-agents/08-09/pack-7ac677fc7b0621d00ee9b2e0ab1512c230d76589.idx delete mode 100644 08-agents/08-09/pack-7ac677fc7b0621d00ee9b2e0ab1512c230d76589.pack delete mode 100644 08-agents/08-09/pack-7ac677fc7b0621d00ee9b2e0ab1512c230d76589.rev delete mode 100644 08-agents/08-09/packed-refs delete mode 100644 08-agents/08-09/pre-applypatch.sample delete mode 100644 08-agents/08-09/pre-merge-commit.sample delete mode 100644 08-agents/08-09/pre-rebase.sample delete mode 100644 08-agents/08-09/pre-receive.sample delete mode 100644 08-agents/08-09/push-to-checkout.sample delete mode 100644 08-agents/08-09/tmp.txt delete mode 100644 08-agents/08-09/tracing.py delete mode 100644 08-agents/08-09/update.sample create mode 100644 08-agents/08-10-hosted-copilot-sdk-agent/.gitignore create mode 100644 08-agents/08-10-hosted-copilot-sdk-agent/08-10-00-hosted-copilot-sdk-agent.md create mode 100644 08-agents/08-10-hosted-copilot-sdk-agent/08-10-01-deploy-hosted-copilot-sdk-agent.ipynb create mode 100644 08-agents/08-10-hosted-copilot-sdk-agent/data/m365-licenses.csv create mode 100644 08-agents/08-10-hosted-copilot-sdk-agent/data/m365-reference.json rename 08-agents/{08-09/README.md => 08-10-hosted-copilot-sdk-agent/infra/abbreviations.json} (97%) create mode 100644 08-agents/08-10-hosted-copilot-sdk-agent/infra/core/ai/acr-role-assignment.bicep create mode 100644 08-agents/08-10-hosted-copilot-sdk-agent/infra/core/ai/ai-project.bicep create mode 100644 08-agents/08-10-hosted-copilot-sdk-agent/infra/core/ai/connection.bicep create mode 100644 08-agents/08-10-hosted-copilot-sdk-agent/infra/core/ai/existing-ai-project.bicep create mode 100644 08-agents/08-10-hosted-copilot-sdk-agent/infra/core/host/acr.bicep rename 08-agents/{08-09/README (14).md => 08-10-hosted-copilot-sdk-agent/infra/core/monitor/applicationinsights-dashboard.bicep} (97%) rename 08-agents/{08-09/invoke.sh => 08-10-hosted-copilot-sdk-agent/infra/core/monitor/applicationinsights.bicep} (97%) rename 08-agents/{08-09/azure.yaml => 08-10-hosted-copilot-sdk-agent/infra/core/monitor/loganalytics.bicep} (95%) create mode 100644 08-agents/08-10-hosted-copilot-sdk-agent/infra/core/search/azure_ai_search.bicep create mode 100644 08-agents/08-10-hosted-copilot-sdk-agent/infra/core/search/bing_custom_grounding.bicep create mode 100644 08-agents/08-10-hosted-copilot-sdk-agent/infra/core/search/bing_grounding.bicep create mode 100644 08-agents/08-10-hosted-copilot-sdk-agent/infra/core/storage/storage.bicep rename 08-agents/{08-09/env.example => 08-10-hosted-copilot-sdk-agent/infra/main.bicep} (98%) create mode 100644 08-agents/08-10-hosted-copilot-sdk-agent/src/github-copilot-invocations/.dockerignore create mode 100644 08-agents/08-10-hosted-copilot-sdk-agent/src/github-copilot-invocations/Dockerfile create mode 100644 08-agents/08-10-hosted-copilot-sdk-agent/src/github-copilot-invocations/README.md create mode 100644 08-agents/08-10-hosted-copilot-sdk-agent/src/github-copilot-invocations/main.py create mode 100644 08-agents/08-10-hosted-copilot-sdk-agent/src/github-copilot-invocations/requirements.txt create mode 100644 08-agents/08-10-hosted-copilot-sdk-agent/src/github-copilot-invocations/skills/m365-license-analytics/SKILL.md create mode 100644 08-agents/08-10-hosted-copilot-sdk-agent/src/github-copilot-invocations/system_prompt.md create mode 100644 08-agents/08-10-hosted-copilot-sdk-agent/src/github-copilot-invocations/tracing.py diff --git a/08-agents/08-09-invoke-agent-via-rest/08-09-00-invoke-agent-via-rest.md b/08-agents/08-09-invoke-agent-via-rest/08-09-00-invoke-agent-via-rest.md new file mode 100644 index 0000000..a392bd3 --- /dev/null +++ b/08-agents/08-09-invoke-agent-via-rest/08-09-00-invoke-agent-via-rest.md @@ -0,0 +1,185 @@ +# Invoke a Foundry-hosted agent via REST + +Every other notebook in `08-agents` invokes agents through the SDK wrapper: + +```python +openai_client = project_client.get_openai_client() +response = openai_client.responses.create( + input=[{"role": "user", "content": "Tell me a one line story"}], + extra_body={"agent_reference": {"name": agent.name, "type": "agent_reference"}}, +) +``` + +That SDK call is a thin wrapper over an OpenAI-compatible HTTP endpoint. This lab strips the wrapper away and calls the same endpoint with `requests`, so the wire-level contract is visible. There is no new agent capability here - the point is to make the underlying protocol explicit and portable (any language, no SDK). + +--- + +## Contents + +1. [Concepts](#1-concepts) +2. [Endpoint and authentication](#2-endpoint-and-authentication) +3. [Request and response shape](#3-request-and-response-shape) +4. [SDK call vs raw REST](#4-sdk-call-vs-raw-rest) +5. [Notebooks in this lab](#5-notebooks-in-this-lab) +6. [Prerequisites](#6-prerequisites) +7. [When to prefer REST over the SDK](#7-when-to-prefer-rest-over-the-sdk) +8. [Primary sources](#8-primary-sources) + +--- + +## 1. Concepts + +The Foundry Agent Service exposes its agent runtime as an OpenAI-compatible **Responses API**. The same surface that the OpenAI Python SDK targets when you call `client.responses.create()` is reachable directly over HTTPS with any HTTP client. + +Two facts make this work: + +| Fact | Source | +|------|--------| +| The OpenAI client returned by `project_client.get_openai_client()` has its `base_url` set to `{project_endpoint}/openai/v1`. | `azure/ai/projects/_patch.py` (the SDK's `get_openai_client` method). | +| The same client uses a bearer token scoped to `https://ai.azure.com/.default` for authentication. | Same file - `get_bearer_token_provider(credential, "https://ai.azure.com/.default")`. | + +Together they pin down the REST contract: a `POST` to `{project_endpoint}/openai/v1/responses` with an `Authorization: Bearer ` header. + +--- + +## 2. Endpoint and authentication + +**Endpoint shape:** + +``` +POST {ALPHA_FOUNDRY_PROJECT_ENDPOINT}/openai/v1/responses +``` + +For example, if `ALPHA_FOUNDRY_PROJECT_ENDPOINT` is +`https://alpha-foundry.services.ai.azure.com/api/projects/alpha-proj`, the full URL is +`https://alpha-foundry.services.ai.azure.com/api/projects/alpha-proj/openai/v1/responses`. + +**Authentication:** + +| Field | Value | +|-------|-------| +| Header | `Authorization: Bearer ` | +| Token audience (scope) | `https://ai.azure.com/.default` | +| How to obtain | `DefaultAzureCredential().get_token("https://ai.azure.com/.default")` | + +The token expires (typically after one hour). The notebooks fetch a token once per invocation for clarity; production code should cache it and refresh on `401`. + +--- + +## 3. Request and response shape + +**Request body** (JSON): + +```json +{ + "input": [ + { "role": "user", "content": "Tell me a one line story" } + ], + "agent_reference": { + "name": "storytelling-agent", + "type": "agent_reference" + } +} +``` + +- `input` accepts the same shapes the SDK accepts: a plain string, a list of message objects, or a list mixing messages and tool outputs. +- `agent_reference` is the same object the SDK puts in `extra_body`. With `type: "agent_reference"` and a `name`, the service resolves the latest version of that agent; add `"version": ""` to pin a specific version. + +**Response body** (JSON, abbreviated): + +```json +{ + "id": "resp_01H...", + "object": "response", + "status": "completed", + "output": [ + { + "type": "message", + "role": "assistant", + "content": [ + { "type": "output_text", "text": "..." } + ] + } + ], + "output_text": "..." +} +``` + +The `output_text` field is a convenience aggregate of all `output_text` parts across the output items. `output` is the structured form (and is where `function_call`, `mcp_tool_call`, and other item types appear). + +**Multi-turn:** add `"previous_response_id": "resp_..."` at the top level on the follow-up call. See `08-09-02-rest-multi-turn.ipynb`. + +**Streaming:** add `"stream": true` to receive a `text/event-stream` response. See `08-09-03-rest-streaming.ipynb`. + +--- + +## 4. SDK call vs raw REST + +The two are equivalent: + +```python +# SDK (from 08-01) +openai_client.responses.create( + input=[{"role": "user", "content": "Tell me a one line story"}], + extra_body={"agent_reference": {"name": "storytelling-agent", "type": "agent_reference"}}, +) +``` + +```python +# Raw REST (this lab) +requests.post( + f"{endpoint}/openai/v1/responses", + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + json={ + "input": [{"role": "user", "content": "Tell me a one line story"}], + "agent_reference": {"name": "storytelling-agent", "type": "agent_reference"}, + }, +) +``` + +What `extra_body` does in the SDK is exactly what flattening the dict into the JSON body does in the REST call. + +--- + +## 5. Notebooks in this lab + +| Notebook | Pattern | +|----------|---------| +| [`08-09-01-rest-single-shot.ipynb`](08-09-01-rest-single-shot.ipynb) | One `POST /responses`, print `output_text`. The smallest possible REST invocation. | +| [`08-09-02-rest-multi-turn.ipynb`](08-09-02-rest-multi-turn.ipynb) | Two `POST`s linked by `previous_response_id`, showing the same continuation primitive that HITL uses to submit tool results. | +| [`08-09-03-rest-streaming.ipynb`](08-09-03-rest-streaming.ipynb) | `stream: true` plus SSE parsing - reads `response.output_text.delta` events and prints text incrementally. | + +All three target the same agent (`storytelling-agent` from `08-01`) so the variable is the protocol pattern, not the agent. + +--- + +## 6. Prerequisites + +1. Run [`08-01-create-versioned-storytelling-agent.ipynb`](../08-01-create-versioned-storytelling-agent.ipynb) once so that an agent named `storytelling-agent` exists in your project. +2. `ALPHA_FOUNDRY_PROJECT_ENDPOINT` set in the repo `.env` (the same variable every other 08-agents notebook reads). +3. Authenticated `az login` session - `DefaultAzureCredential` falls back to your Azure CLI credentials. + +No new packages are required - `requests` and `azure-identity` are already in the repo's `pyproject.toml`. + +--- + +## 7. When to prefer REST over the SDK + +The SDK is the right default for production Python code: typed output items, automatic token refresh, retries, and streaming helpers come for free. Reach for raw REST when: + +| Reason | Example | +|--------|---------| +| Non-Python language | A Go, Rust, .NET, or shell client that does not have a Foundry SDK. | +| Debugging the wire format | Confirming what headers, query parameters, or body shape the SDK sends. | +| Minimal-dependency environments | Edge functions or containers where pulling in `openai` + `azure-ai-projects` is too heavy. | +| Pinning behaviour | The SDK can change defaults across versions; a raw REST call freezes the contract you depend on. | + +Trade-off: you give up SDK conveniences and own token refresh, retry, error parsing, and SSE parsing yourself. + +--- + +## 8. Primary sources + +- [Azure AI Foundry Responses API reference](https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/responses) - the canonical contract. +- [OpenAI Responses API reference](https://platform.openai.com/docs/api-reference/responses) - field-by-field documentation for `input`, `output`, `previous_response_id`, and the streaming event types. +- `azure-ai-projects` `get_openai_client` implementation - source of the `{endpoint}/openai/v1` base URL and `https://ai.azure.com/.default` token scope used here. diff --git a/08-agents/08-09-invoke-agent-via-rest/08-09-01-rest-single-shot.ipynb b/08-agents/08-09-invoke-agent-via-rest/08-09-01-rest-single-shot.ipynb new file mode 100644 index 0000000..6fb791b --- /dev/null +++ b/08-agents/08-09-invoke-agent-via-rest/08-09-01-rest-single-shot.ipynb @@ -0,0 +1,218 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "title-cell", + "metadata": {}, + "source": [ + "# Invoke a Foundry agent via REST - single-shot\n", + "\n", + "Mirror of step 4 in [`08-01-create-versioned-storytelling-agent.ipynb`](../08-01-create-versioned-storytelling-agent.ipynb) (the `responses.create` call), executed directly with `requests` instead of the OpenAI SDK. Targets the `storytelling-agent` created in 08-01.\n", + "\n", + "See [`08-09-00-invoke-agent-via-rest.md`](08-09-00-invoke-agent-via-rest.md) for the endpoint, auth, and body-shape contract this notebook implements." + ] + }, + { + "cell_type": "markdown", + "id": "section-1", + "metadata": {}, + "source": [ + "## 1. Setup\n", + "\n", + "Load `ALPHA_FOUNDRY_PROJECT_ENDPOINT` from the repo `.env` and derive the Responses URL: `{endpoint}/openai/v1/responses`. This is the same `base_url + /responses` that `project_client.get_openai_client()` configures internally." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "setup-code", + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "import os\n", + "import subprocess\n", + "from pathlib import Path\n", + "\n", + "import requests\n", + "from azure.identity import DefaultAzureCredential\n", + "from dotenv import load_dotenv\n", + "\n", + "AGENT_NAME = 'storytelling-agent'\n", + "\n", + "repo_root = Path(subprocess.run(\n", + " 'git rev-parse --show-toplevel', shell=True, capture_output=True, text=True\n", + ").stdout.strip())\n", + "load_dotenv(repo_root / '.env', override=True)\n", + "\n", + "endpoint = os.environ['ALPHA_FOUNDRY_PROJECT_ENDPOINT'].rstrip('/')\n", + "responses_url = f'{endpoint}/openai/v1/responses'\n", + "\n", + "print(f'Endpoint : {endpoint}')\n", + "print(f'Responses URL: {responses_url}')\n", + "print(f'Agent name : {AGENT_NAME}')" + ] + }, + { + "cell_type": "markdown", + "id": "section-2", + "metadata": {}, + "source": [ + "## 2. Get a bearer token\n", + "\n", + "Audience is `https://ai.azure.com/.default` - the same scope the SDK uses (see `get_bearer_token_provider(credential, 'https://ai.azure.com/.default')` in `azure-ai-projects` `_patch.py`)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "token-code", + "metadata": {}, + "outputs": [], + "source": [ + "credential = DefaultAzureCredential()\n", + "access_token = credential.get_token('https://ai.azure.com/.default').token\n", + "print(f'Token acquired (length: {len(access_token)} chars)')" + ] + }, + { + "cell_type": "markdown", + "id": "section-3", + "metadata": {}, + "source": [ + "## 3. Build the request body\n", + "\n", + "The JSON body is exactly what the SDK assembles from the `input` argument plus `extra_body={'agent_reference': ...}`. `agent_reference.name` resolves to the latest version of the agent; add `'version': ''` to pin a specific version." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "body-code", + "metadata": {}, + "outputs": [], + "source": [ + "headers = {\n", + " 'Authorization': f'Bearer {access_token}',\n", + " 'Content-Type': 'application/json',\n", + "}\n", + "\n", + "body = {\n", + " 'input': [\n", + " {'role': 'user', 'content': 'Tell me a one line story about a curious robot.'}\n", + " ],\n", + " 'agent_reference': {\n", + " 'name': AGENT_NAME,\n", + " 'type': 'agent_reference',\n", + " },\n", + "}\n", + "\n", + "print(json.dumps(body, indent=2))" + ] + }, + { + "cell_type": "markdown", + "id": "section-4", + "metadata": {}, + "source": [ + "## 4. POST to /responses\n", + "\n", + "The single call that replaces `openai_client.responses.create(...)`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "post-code", + "metadata": {}, + "outputs": [], + "source": [ + "response = requests.post(responses_url, headers=headers, json=body, timeout=60)\n", + "response.raise_for_status()\n", + "result = response.json()\n", + "\n", + "# The raw REST JSON has no `output_text` key. That field is a convenience the\n", + "# OpenAI SDK's typed Response synthesises by concatenating every output_text\n", + "# part. Over REST we aggregate it ourselves from the structured `output` array.\n", + "output_text = ''.join(\n", + " part['text']\n", + " for item in result.get('output', [])\n", + " if item.get('type') == 'message'\n", + " for part in item.get('content', [])\n", + " if part.get('type') == 'output_text'\n", + ")\n", + "\n", + "print(f'HTTP status : {response.status_code}')\n", + "print(f'Response id : {result[\"id\"]}')\n", + "print(f'Status : {result[\"status\"]}')\n", + "print()\n", + "print('Output text:')\n", + "print(output_text)" + ] + }, + { + "cell_type": "markdown", + "id": "section-5", + "metadata": {}, + "source": [ + "## 5. Inspect the structured output\n", + "\n", + "`output_text` is a convenience aggregate of all `output_text` parts across the output items. The structured `output` array is where richer item types (`function_call`, `mcp_tool_call`, `mcp_approval_request`, etc) appear in more complex agents." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "inspect-code", + "metadata": {}, + "outputs": [], + "source": [ + "for i, item in enumerate(result.get('output', [])):\n", + " print(f'output[{i}].type = {item.get(\"type\")}')\n", + " if item.get('type') == 'message':\n", + " for j, part in enumerate(item.get('content', [])):\n", + " print(f' content[{j}].type = {part.get(\"type\")}')\n", + " if part.get('type') == 'output_text':\n", + " print(f' content[{j}].text = {part.get(\"text\")}')" + ] + }, + { + "cell_type": "markdown", + "id": "section-summary", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "| Step | SDK (`openai_client.responses.create`) | REST equivalent (this notebook) |\n", + "|------|----------------------------------------|---------------------------------|\n", + "| URL | implicit - `base_url = {endpoint}/openai/v1` | `f'{endpoint}/openai/v1/responses'` |\n", + "| Auth | `get_bearer_token_provider(credential, 'https://ai.azure.com/.default')` | `credential.get_token('https://ai.azure.com/.default').token` |\n", + "| Body | `input=...`, `extra_body={'agent_reference': ...}` | dict with `input` + `agent_reference` keys |\n", + "| Response | typed `Response` object - `.output_text`, `.output`, `.id` | JSON dict - `output`, `id` (no `output_text`; aggregate it yourself from `output`) |\n", + "\n", + "Next: [`08-09-02-rest-multi-turn.ipynb`](08-09-02-rest-multi-turn.ipynb) continues a response chain using `previous_response_id`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "awesome-foundry-nextgen", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/08-agents/08-09-invoke-agent-via-rest/08-09-02-rest-multi-turn.ipynb b/08-agents/08-09-invoke-agent-via-rest/08-09-02-rest-multi-turn.ipynb new file mode 100644 index 0000000..b427dad --- /dev/null +++ b/08-agents/08-09-invoke-agent-via-rest/08-09-02-rest-multi-turn.ipynb @@ -0,0 +1,234 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "title-cell", + "metadata": {}, + "source": [ + "# Invoke a Foundry agent via REST - multi-turn\n", + "\n", + "Extends [`08-09-01-rest-single-shot.ipynb`](08-09-01-rest-single-shot.ipynb) by chaining two `POST /responses` calls together using `previous_response_id`. The result is the same continuation primitive that [`08-08-01-human-in-the-loop.ipynb`](../08-08-human-in-the-loop/08-08-01-human-in-the-loop.ipynb) uses to submit tool results back to the agent - the difference is what the second turn carries (a follow-up user message here, `function_call_output` items in HITL)." + ] + }, + { + "cell_type": "markdown", + "id": "section-1", + "metadata": {}, + "source": [ + "## 1. Setup\n", + "\n", + "Identical to the single-shot notebook - same endpoint, same token scope, same agent. The only difference is the conversation now spans two requests." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "setup-code", + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "import os\n", + "import subprocess\n", + "from pathlib import Path\n", + "\n", + "import requests\n", + "from azure.identity import DefaultAzureCredential\n", + "from dotenv import load_dotenv\n", + "\n", + "AGENT_NAME = 'storytelling-agent'\n", + "\n", + "repo_root = Path(subprocess.run(\n", + " 'git rev-parse --show-toplevel', shell=True, capture_output=True, text=True\n", + ").stdout.strip())\n", + "load_dotenv(repo_root / '.env', override=True)\n", + "\n", + "endpoint = os.environ['ALPHA_FOUNDRY_PROJECT_ENDPOINT'].rstrip('/')\n", + "responses_url = f'{endpoint}/openai/v1/responses'\n", + "\n", + "credential = DefaultAzureCredential()\n", + "access_token = credential.get_token('https://ai.azure.com/.default').token\n", + "\n", + "headers = {\n", + " 'Authorization': f'Bearer {access_token}',\n", + " 'Content-Type': 'application/json',\n", + "}\n", + "agent_ref = {'name': AGENT_NAME, 'type': 'agent_reference'}\n", + "\n", + "\n", + "def output_text(result):\n", + " \"\"\"Aggregate the visible text from a raw Responses REST payload.\n", + "\n", + " The wire JSON has no top-level `output_text` key - that is a convenience the\n", + " OpenAI SDK's typed Response synthesises by concatenating every output_text\n", + " part across the structured `output` array. Over REST we do it ourselves.\n", + " \"\"\"\n", + " return ''.join(\n", + " part['text']\n", + " for item in result.get('output', [])\n", + " if item.get('type') == 'message'\n", + " for part in item.get('content', [])\n", + " if part.get('type') == 'output_text'\n", + " )\n", + "\n", + "\n", + "print(f'Responses URL: {responses_url}')\n", + "print(f'Agent ref : {agent_ref}')" + ] + }, + { + "cell_type": "markdown", + "id": "section-2", + "metadata": {}, + "source": [ + "## 2. Turn 1 - open the conversation\n", + "\n", + "First POST is identical to the single-shot. The new step is capturing `result['id']` so the next turn can reference it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "turn1-code", + "metadata": {}, + "outputs": [], + "source": [ + "turn1_body = {\n", + " 'input': [\n", + " {'role': 'user', 'content': 'Invent a one line story about an astronaut named Mira.'}\n", + " ],\n", + " 'agent_reference': agent_ref,\n", + "}\n", + "\n", + "r1 = requests.post(responses_url, headers=headers, json=turn1_body, timeout=60)\n", + "r1.raise_for_status()\n", + "turn1 = r1.json()\n", + "\n", + "turn1_id = turn1['id']\n", + "print(f'Turn 1 response id: {turn1_id}')\n", + "print()\n", + "print('Turn 1 output:')\n", + "print(output_text(turn1))" + ] + }, + { + "cell_type": "markdown", + "id": "section-3", + "metadata": {}, + "source": [ + "## 3. Turn 2 - continue with previous_response_id\n", + "\n", + "The second POST sends a new user message **and** sets `previous_response_id` to the first turn's id. The service rehydrates the prior conversation state on its side - the client does not have to resend turn 1's messages.\n", + "\n", + "This is the same field name and semantics the SDK uses:\n", + "\n", + "```python\n", + "openai_client.responses.create(\n", + " input=...,\n", + " previous_response_id=turn1.id,\n", + " extra_body={'agent_reference': agent_ref},\n", + ")\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "turn2-code", + "metadata": {}, + "outputs": [], + "source": [ + "turn2_body = {\n", + " 'input': [\n", + " {'role': 'user', 'content': 'Now tell me what happens next, in one line.'}\n", + " ],\n", + " 'previous_response_id': turn1_id,\n", + " 'agent_reference': agent_ref,\n", + "}\n", + "\n", + "r2 = requests.post(responses_url, headers=headers, json=turn2_body, timeout=60)\n", + "r2.raise_for_status()\n", + "turn2 = r2.json()\n", + "\n", + "print(f'Turn 2 response id: {turn2[\"id\"]}')\n", + "print(f'previous_response_id sent: {turn1_id}')\n", + "print()\n", + "print('Turn 2 output:')\n", + "print(output_text(turn2))" + ] + }, + { + "cell_type": "markdown", + "id": "section-4", + "metadata": {}, + "source": [ + "## 4. Reading the full conversation\n", + "\n", + "Each turn returns a fresh `id`. The chain is implicit in the `previous_response_id` field - turn 2 points at turn 1, and a hypothetical turn 3 would point at turn 2. The server holds the conversation history; the client only needs the latest id." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "summary-code", + "metadata": {}, + "outputs": [], + "source": [ + "print('Conversation:')\n", + "print(f' user > {turn1_body[\"input\"][0][\"content\"]}')\n", + "print(f' agent > {output_text(turn1)}')\n", + "print(f' user > {turn2_body[\"input\"][0][\"content\"]}')\n", + "print(f' agent > {output_text(turn2)}')" + ] + }, + { + "cell_type": "markdown", + "id": "section-summary", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "| Step | What changes from single-shot |\n", + "|------|-------------------------------|\n", + "| URL | Same - `{endpoint}/openai/v1/responses` |\n", + "| Auth | Same - bearer token, `https://ai.azure.com/.default` |\n", + "| Turn 1 body | Same as single-shot |\n", + "| Turn 2 body | Adds `'previous_response_id': turn1['id']` |\n", + "| State | Held server-side - client only needs the latest response id |\n", + "\n", + "### Same primitive, different uses\n", + "\n", + "`previous_response_id` is used for three distinct scenarios across this repo:\n", + "\n", + "1. **Multi-turn conversation** (this notebook) - second turn carries a new user message.\n", + "2. **HITL tool-result submission** ([`08-08-01`](../08-08-human-in-the-loop/08-08-01-human-in-the-loop.ipynb)) - second turn carries `function_call_output` items keyed by the tool call ids from turn 1.\n", + "3. **MCP approval response** ([`08-05-02`](../08-05-contoso-pmo-mcp/08-05-02-contoso-pmo-agent-queries.ipynb) - implicit when `require_approval='always'`) - second turn carries an `mcp_approval_response` item.\n", + "\n", + "The wire shape is the same in all three cases - only the items inside `input` differ.\n", + "\n", + "Next: [`08-09-03-rest-streaming.ipynb`](08-09-03-rest-streaming.ipynb) shows the same endpoint with `stream: true` and Server-Sent Events parsing." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "awesome-foundry-nextgen", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/08-agents/08-09-invoke-agent-via-rest/08-09-03-rest-streaming.ipynb b/08-agents/08-09-invoke-agent-via-rest/08-09-03-rest-streaming.ipynb new file mode 100644 index 0000000..aede9bd --- /dev/null +++ b/08-agents/08-09-invoke-agent-via-rest/08-09-03-rest-streaming.ipynb @@ -0,0 +1,249 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "title-cell", + "metadata": {}, + "source": [ + "# Invoke a Foundry agent via REST - streaming (SSE)\n", + "\n", + "Extends [`08-09-01-rest-single-shot.ipynb`](08-09-01-rest-single-shot.ipynb) by adding `\"stream\": true` to the request. The Responses API replies with a `text/event-stream` response - a sequence of Server-Sent Events (SSE), each a small JSON document - rather than one final JSON body. The client parses each event as it arrives, which lets the UI render tokens incrementally instead of waiting for the full answer.\n", + "\n", + "No other 08-agents notebook demonstrates SSE explicitly, so this also serves as the canonical example of the event-type dispatch pattern." + ] + }, + { + "cell_type": "markdown", + "id": "section-1", + "metadata": {}, + "source": [ + "## 1. Setup\n", + "\n", + "Same env, same token scope, same agent. Only the request body and response handling differ." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "setup-code", + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "import os\n", + "import subprocess\n", + "import sys\n", + "from pathlib import Path\n", + "\n", + "import requests\n", + "from azure.identity import DefaultAzureCredential\n", + "from dotenv import load_dotenv\n", + "\n", + "AGENT_NAME = 'storytelling-agent'\n", + "\n", + "repo_root = Path(subprocess.run(\n", + " 'git rev-parse --show-toplevel', shell=True, capture_output=True, text=True\n", + ").stdout.strip())\n", + "load_dotenv(repo_root / '.env', override=True)\n", + "\n", + "endpoint = os.environ['ALPHA_FOUNDRY_PROJECT_ENDPOINT'].rstrip('/')\n", + "responses_url = f'{endpoint}/openai/v1/responses'\n", + "\n", + "credential = DefaultAzureCredential()\n", + "access_token = credential.get_token('https://ai.azure.com/.default').token\n", + "\n", + "headers = {\n", + " 'Authorization': f'Bearer {access_token}',\n", + " 'Content-Type': 'application/json',\n", + " 'Accept': 'text/event-stream',\n", + "}\n", + "\n", + "print(f'Responses URL: {responses_url}')" + ] + }, + { + "cell_type": "markdown", + "id": "section-2", + "metadata": {}, + "source": [ + "## 2. Request body with `stream: true`\n", + "\n", + "Only one field changes from the single-shot body: `stream` is set to `true`. The server's response content type flips from `application/json` to `text/event-stream`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "body-code", + "metadata": {}, + "outputs": [], + "source": [ + "body = {\n", + " 'input': [\n", + " {'role': 'user', 'content': 'Tell me a three-sentence story about a lighthouse keeper.'}\n", + " ],\n", + " 'agent_reference': {\n", + " 'name': AGENT_NAME,\n", + " 'type': 'agent_reference',\n", + " },\n", + " 'stream': True,\n", + "}\n", + "\n", + "print(json.dumps(body, indent=2))" + ] + }, + { + "cell_type": "markdown", + "id": "section-3", + "metadata": {}, + "source": [ + "## 3. SSE parser\n", + "\n", + "The Server-Sent Events format is line-delimited: each event is one or more `field: value` lines followed by a blank line. The Responses API only uses the `data:` field, and each `data:` value is a complete JSON document. So a minimal parser is:\n", + "\n", + "1. Open the response in streaming mode (`stream=True`).\n", + "2. Iterate `iter_lines()` and watch for lines starting with `data: `.\n", + "3. Parse the JSON payload after `data: ` and dispatch on its `type` field.\n", + "\n", + "Event types the Responses API emits (non-exhaustive):\n", + "\n", + "| Event type | Meaning |\n", + "|------------|---------|\n", + "| `response.created` | Stream opened - carries the new `response.id`. |\n", + "| `response.output_item.added` | A new output item (e.g. `message`, `function_call`) was added. |\n", + "| `response.content_part.added` | A new content part inside an item was added. |\n", + "| `response.output_text.delta` | Incremental text chunk for an `output_text` content part. **This is where you accumulate visible tokens.** |\n", + "| `response.output_text.done` | A given `output_text` part is complete. |\n", + "| `response.completed` | The full response is done - carries the final `response` object. |\n", + "| `error` | An error occurred mid-stream. |" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "stream-code", + "metadata": {}, + "outputs": [], + "source": [ + "response_id = None\n", + "accumulated_text = []\n", + "event_counts = {}\n", + "\n", + "with requests.post(responses_url, headers=headers, json=body, stream=True, timeout=120) as resp:\n", + " resp.raise_for_status()\n", + " print(f'HTTP status : {resp.status_code}')\n", + " print(f'Response content-type: {resp.headers.get(\"content-type\")}')\n", + " print()\n", + " print('Streaming output:')\n", + " print('-' * 60)\n", + "\n", + " for raw_line in resp.iter_lines(decode_unicode=True):\n", + " if not raw_line or not raw_line.startswith('data: '):\n", + " continue\n", + "\n", + " payload_str = raw_line[len('data: '):]\n", + " if payload_str == '[DONE]':\n", + " break\n", + "\n", + " event = json.loads(payload_str)\n", + " event_type = event.get('type', '')\n", + " event_counts[event_type] = event_counts.get(event_type, 0) + 1\n", + "\n", + " if event_type == 'response.created':\n", + " response_id = event.get('response', {}).get('id')\n", + " elif event_type == 'response.output_text.delta':\n", + " delta = event.get('delta', '')\n", + " accumulated_text.append(delta)\n", + " sys.stdout.write(delta)\n", + " sys.stdout.flush()\n", + " elif event_type == 'response.completed':\n", + " pass\n", + " elif event_type == 'error':\n", + " print(f'\\n[error] {event}')\n", + "\n", + " print()\n", + " print('-' * 60)\n", + "\n", + "print()\n", + "print(f'Response id : {response_id}')\n", + "print(f'Total characters : {sum(len(s) for s in accumulated_text)}')\n", + "print(f'Event counts : {event_counts}')" + ] + }, + { + "cell_type": "markdown", + "id": "section-4", + "metadata": {}, + "source": [ + "## 4. Inspect the assembled text\n", + "\n", + "The accumulated `response.output_text.delta` chunks concatenated together are equivalent to the aggregated `output_text` the single-shot notebook builds from the non-streaming `output` array. (Neither the streaming events nor the final JSON expose a ready-made `output_text` field - that convenience exists only on the SDK's typed `Response`.)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "assembled-code", + "metadata": {}, + "outputs": [], + "source": [ + "full_text = ''.join(accumulated_text)\n", + "print('Full assembled output:')\n", + "print(full_text)" + ] + }, + { + "cell_type": "markdown", + "id": "section-summary", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "| Step | What changes from single-shot |\n", + "|------|-------------------------------|\n", + "| URL | Same - `{endpoint}/openai/v1/responses` |\n", + "| Auth | Same - bearer token, `https://ai.azure.com/.default` |\n", + "| Body | Adds `'stream': True` |\n", + "| Headers | Adds `Accept: text/event-stream` (optional but explicit) |\n", + "| Request call | `requests.post(..., stream=True)` - keep the response open |\n", + "| Response | Iterate `resp.iter_lines()`, parse JSON after `data: `, dispatch on `event['type']` |\n", + "| Output text | Accumulate `event['delta']` from `response.output_text.delta` events |\n", + "\n", + "### When this matters\n", + "\n", + "Streaming pays off for:\n", + "\n", + "- **Interactive UIs** - render tokens as they arrive, lower perceived latency.\n", + "- **Long-running tool loops** - observe `function_call` items and tool progress events without waiting for the full response.\n", + "- **Cancellation** - the client can abort an in-progress request before the agent finishes generating.\n", + "\n", + "The trade-off is parser complexity: the JSON-document-per-line response of the non-streaming call becomes an event stream that the client must reassemble.\n", + "\n", + "### Related\n", + "\n", + "- [Foundry SDK streaming](https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/responses) - the SDK exposes `client.responses.stream(...)` which wraps this exact event protocol." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "awesome-foundry-nextgen", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/08-agents/08-09/Dockerfile b/08-agents/08-09/Dockerfile deleted file mode 100644 index 1836fa7..0000000 --- a/08-agents/08-09/Dockerfile +++ /dev/null @@ -1 +0,0 @@ -0000000000000000000000000000000000000000 114f0012b64de74ca55c772f4a4b91cf345d30a3 ArlindNocaj <2978097+ArlindNocaj@users.noreply.github.com> 1778824870 +0200 clone: from https://github.com/ArlindNocaj/foundry-copilot-sdk-hosted.git diff --git a/08-agents/08-09/HEAD b/08-agents/08-09/HEAD deleted file mode 100644 index 9e26dfe..0000000 --- a/08-agents/08-09/HEAD +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/08-agents/08-09/HEAD (10) b/08-agents/08-09/HEAD (10) deleted file mode 100644 index ec17ec1..0000000 --- a/08-agents/08-09/HEAD (10) +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/sh -# -# An example hook script to prepare a packed repository for use over -# dumb transports. -# -# To enable this hook, rename this file to "post-update". - -exec git update-server-info diff --git a/08-agents/08-09/HEAD (9) b/08-agents/08-09/HEAD (9) deleted file mode 100644 index 10fa14c..0000000 --- a/08-agents/08-09/HEAD (9) +++ /dev/null @@ -1,42 +0,0 @@ -#!/bin/sh -# -# An example hook script to prepare the commit log message. -# Called by "git commit" with the name of the file that has the -# commit message, followed by the description of the commit -# message's source. The hook's purpose is to edit the commit -# message file. If the hook fails with a non-zero status, -# the commit is aborted. -# -# To enable this hook, rename this file to "prepare-commit-msg". - -# This hook includes three examples. The first one removes the -# "# Please enter the commit message..." help message. -# -# The second includes the output of "git diff --name-status -r" -# into the message, just before the "git status" output. It is -# commented because it doesn't cope with --amend or with squashed -# commits. -# -# The third example adds a Signed-off-by line to the message, that can -# still be edited. This is rarely a good idea. - -COMMIT_MSG_FILE=$1 -COMMIT_SOURCE=$2 -SHA1=$3 - -/usr/bin/perl -i.bak -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' "$COMMIT_MSG_FILE" - -# case "$COMMIT_SOURCE,$SHA1" in -# ,|template,) -# /usr/bin/perl -i.bak -pe ' -# print "\n" . `git diff --cached --name-status -r` -# if /^#/ && $first++ == 0' "$COMMIT_MSG_FILE" ;; -# *) ;; -# esac - -# SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') -# git interpret-trailers --in-place --trailer "$SOB" "$COMMIT_MSG_FILE" -# if test -z "$COMMIT_SOURCE" -# then -# /usr/bin/perl -i.bak -pe 'print "\n" if !$first_line++' "$COMMIT_MSG_FILE" -# fi diff --git a/08-agents/08-09/abbreviations.json b/08-agents/08-09/abbreviations.json deleted file mode 100644 index 399eab1..0000000 --- a/08-agents/08-09/abbreviations.json +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/sh -# -# An example hook script to verify what is about to be committed. -# Called by "git merge" with no arguments. The hook should -# exit with non-zero status after issuing an appropriate message to -# stderr if it wants to stop the merge commit. -# -# To enable this hook, rename this file to "pre-merge-commit". - -. git-sh-setup -test -x "$GIT_DIR/hooks/pre-commit" && - exec "$GIT_DIR/hooks/pre-commit" -: diff --git a/08-agents/08-09/acr-role-assignment.bicep b/08-agents/08-09/acr-role-assignment.bicep deleted file mode 100644 index 5d2c3ea..0000000 --- a/08-agents/08-09/acr-role-assignment.bicep +++ /dev/null @@ -1 +0,0 @@ -114f0012b64de74ca55c772f4a4b91cf345d30a3 diff --git a/08-agents/08-09/agent.yaml b/08-agents/08-09/agent.yaml deleted file mode 100644 index 4b0a875..0000000 --- a/08-agents/08-09/agent.yaml +++ /dev/null @@ -1 +0,0 @@ -ref: refs/remotes/origin/main diff --git a/08-agents/08-09/applicationinsights.bicep b/08-agents/08-09/applicationinsights.bicep deleted file mode 100644 index af5a0c0..0000000 --- a/08-agents/08-09/applicationinsights.bicep +++ /dev/null @@ -1,78 +0,0 @@ -#!/bin/sh - -# An example hook script to update a checked-out tree on a git push. -# -# This hook is invoked by git-receive-pack(1) when it reacts to git -# push and updates reference(s) in its repository, and when the push -# tries to update the branch that is currently checked out and the -# receive.denyCurrentBranch configuration variable is set to -# updateInstead. -# -# By default, such a push is refused if the working tree and the index -# of the remote repository has any difference from the currently -# checked out commit; when both the working tree and the index match -# the current commit, they are updated to match the newly pushed tip -# of the branch. This hook is to be used to override the default -# behaviour; however the code below reimplements the default behaviour -# as a starting point for convenient modification. -# -# The hook receives the commit with which the tip of the current -# branch is going to be updated: -commit=$1 - -# It can exit with a non-zero status to refuse the push (when it does -# so, it must not modify the index or the working tree). -die () { - echo >&2 "$*" - exit 1 -} - -# Or it can make any necessary changes to the working tree and to the -# index to bring them to the desired state when the tip of the current -# branch is updated to the new commit, and exit with a zero status. -# -# For example, the hook can simply run git read-tree -u -m HEAD "$1" -# in order to emulate git fetch that is run in the reverse direction -# with git push, as the two-tree form of git read-tree -u -m is -# essentially the same as git switch or git checkout that switches -# branches while keeping the local changes in the working tree that do -# not interfere with the difference between the branches. - -# The below is a more-or-less exact translation to shell of the C code -# for the default behaviour for git's push-to-checkout hook defined in -# the push_to_deploy() function in builtin/receive-pack.c. -# -# Note that the hook will be executed from the repository directory, -# not from the working tree, so if you want to perform operations on -# the working tree, you will have to adapt your code accordingly, e.g. -# by adding "cd .." or using relative paths. - -if ! git update-index -q --ignore-submodules --refresh -then - die "Up-to-date check failed" -fi - -if ! git diff-files --quiet --ignore-submodules -- -then - die "Working directory has unstaged changes" -fi - -# This is a rough translation of: -# -# head_has_history() ? "HEAD" : EMPTY_TREE_SHA1_HEX -if git cat-file -e HEAD 2>/dev/null -then - head=HEAD -else - head=$(git hash-object -t tree --stdin st!Fi<1AvjvqSf92q!$68^BjK16uu|h=46jZ%Mj)VkkOH856}4| zyJY+dMzXP#L=xw)g@l|CN@XMtXwLf7^O{6FA1&S(1MhJ+Pdlf0JC&Tj749H~hP?09&7BC8N z`7&u7A!Nr>GO3!1xEm7OXf8~|oyp`H2FXX|uKUSxt4;&E)*t75W{hcaZJi#+gND9(wwc<9-2hFxlEq!qhF?3v^~0Oknw2i zxF}cSqUN-#VK;$6Sio%m-~hHn0QXa~WSkIGXcBSeF*$nQS(sn{zUFQ0=x-}3Z(W>{ za@O0kY#m_H+n$|_NyLnOw4>!HJuaAt$7S-&N!CgAp8zPr8sYxIrLij; z^4=?gra<$G&A6IG+;ArM@T+IHwmA823befwn{eh{*iqYQ{CaEP)sHKYNeQ`(w@6O82rx>-lVPMlt8OL5PP7*$@>B%8$o6o#$>t?J63dcykh(quA zr5LeP{r5)`%`Z#crb*<9W_pHg$;avgVur7n?FW7sbk20-GoyVYR{@uP^FbaSTz5

St=xZ@2l`1A8n|9<-69?KA6ZCqvabT-qW@?BPvz3q0wo#g)@KVm9aZL!KBal zJ|Uv{hmWhzS(wPPi0LtUk>Z&&cC+&BQnEDfT%nC&?duS)lfb6G2QEfTh;!hKdQ&qM znt$X{lqL~>F_S;#qIAPb@fGFoIS+htf6mdrbFJt}kO?qW^CcMZ@dThbRbLXCM7$+T z-q6=OO81tTdHmd?7x~b)?nleumj}cwVA+qCfRUI4m*b@71XiE$G`E_&Sd)k!3;B=w z`b7P@`Co{Kc2))6{dQ{Uh0<+lmB!Pu_5uS!nj~9>vni4KoT@pl=Dt_RK($>+dcQdAIN?_eaTC!le_=bwp!U@qFnw&nHPN)*bU^PTf9Dx@{Rg@hlxCV zrbq93Qddz?!}y3f%~t6J89QIP9rs|7+h;-)KU6VIr@P>DA-Ex)*#fy}C8xt5@TV>;s#V zaMpC=0qdyt(rpsWKgG4yB=U%u9?Mqzv1j*X-m2_ZhMq=8&zsx&7xp-UNdwG}J_r($ zdzS{yKdo4xN#r4z9&&qOHT4Ru6SK)S%{?NuiBU#qSa^OCIk6K2E#S|wVo*kU< zKyFY_Z|AChMT7;w9$KdxxPNKm&f1d8{wCz0!g|8g6USf7T{~X2^4@F62Ch?VTv=a7 zvaX&vnd%qO5IE@FDBkPv7x!m>{t!|B#*KXbWaIV-D_PZ%3nTUxSXkd9xa!dWSBU&$ zPiRsH6?(Yw>21H^_Xl|?^bD@{juL)2h1ER6P3GhiciwFM8;_?xn6<~7`6cuL3tT;W zhf^9{uX<6&Y3O+DKn_20d+me8?|M5_;Ob`M$%@7ny1(~2Bd_->br8VXn!M-XQ{&-5^cs+-%^1) z{B6C!Ywlp*obvN^&I=i%Pri<2N~(>g{p9oN0d=-LYsq z$35NJDJC^Um6G)vx%=2e)6UK;^}`mJX5+?EO<$kt<8 diff --git a/08-agents/08-09/azure_ai_search.bicep b/08-agents/08-09/azure_ai_search.bicep deleted file mode 100644 index 1a7e86a188763c53b1e8f6cdd327f81151b42e39..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 276 zcmXZRISv5<0EOYP?}oAO`@W6+03@PSxqvfBEQP2<;tCEx

H2jZUkeP`QA@FY%Hu zulNW3?obc}#h;3|Ktu!u=053MFuH^ z;ei1GcoBsaHUwcr8ZG#dg9!<=;YJA!CJXMdtSaz3mZKE diff --git a/08-agents/08-09/bing_custom_grounding.bicep b/08-agents/08-09/bing_custom_grounding.bicep deleted file mode 100644 index 5a008a643ce6bf6c0fe649ce7646c5d7680bb4cb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 33305 zcmZs?V{|II(Tp#y7TYJ2|m!+qP}nwr%4inctoH*VL`4dC)Il_wMS??zL7c z2#ElIfPg^$Tbv1~9@#0e06P#SB+)m1u{&gvUp{e45Jwb0NC|u|6W)aVXGawJcwP0C ztJ_ODuz6urY_Nj`OtvIJF6C7%RI;?D{AENe(Pax3x@3fGv1!(bWDj;HjG~l411=U@ zUDDx8$0^5{>D{{)p^91WOo+2L2Tir4$K%USV9^RqOWJmS&{E8zgW8I>`#Cn37yL&c zmh`kRgbG10XkU)XWGiWy)^7+tK=XKmXzB1N%YTi=f zr%zj7+yY00g5FIg(*G2+024%rDSqb@`O6Q(rp;vp7BjGZk}EhfS%77R(erH-wR3lS zJ#!jw4xZOn4A z11_nlMmE-N6mJm8v{}KzbSYqe&Yxis9J0Wm=I%E-vVq&N?l zqn(hMn_`eQRp9!4f7|?Y2cZ_rx+#f%OtNF`{lgMm4&&tN^5D8MC2QDjMdkZ&gF2hq zwq!C02cDX3+m7x+P)XTP*-WWg$IQeLCZuzH8*1BkM4Gv3>*+9%e;J-~k%@o`N~kbC z4WN-Wxz|4EdV6eR`wX+|TcSMNd9Qy_oz~B%IxQ^~1ULQIsPG)Yg2R90!i{7160l^} zck~>24wVH-y@4ePMZ+Lt8E)-ET*+xOQ#1}(k3JfawRg{L+d%*VXlR_?fcmR?WOog| zrzkEA13{rZqf|*pDPhrXxv2G0Iq0Hhe>#`}PHtupf1dKP90Z3MlCM?|d|Kp)!n!qo zV937VeqI-mfk>!uGYkL!x99rq)ZRiW?^+8DdGr3X+_l7R%2}km@-T5YQDx^idbs;p z1wxJ{;|tQIvbs5qzz^wl?JQ5tQxsms(8*U%%dAsR&CwTMBwZtNBf^~;+zh@E!U&Wu=5yn z^`x@W6sDdf*i(uxAe!fLWPS#%?N(!LKE*USQ7*ir=t~*LMr7Y;YU!vrh z*qzENuo#o$NSm@~Hi^VjlEm+LFqp5Gsx6O2|Rsxv@0qt1OCC7*D z2T&V*p3(#3G4cRYc^$~DAIXozGs{;sRn&obEi@_0J4LrKkz5yMdUxa_%`kDi&!YJ5 zp<%TW%i>Zc?-Sa&N^qEu%s-B`i}3OW_NXWNk?2OmF-8eRlpE0EPm-h=okz@7e>$BE zv!5Oa?$am?sS8ON=1u}5IF+^Uc^G35h+;f`V6BUJ@A_e7 zbhPmUMkMdz{x|`iAl#I4G24X@sjLkocdVCJ6x|hk`Bbv6wI8PY zN*gp5(vMl8E8uX7qDzy-K72-&IuQ{~n`?Q-zC;bP_AjO>ZkCbuhF)EYv&lXGIHu-W z3PI3Jj+K~$#|sRx=8!EHZ>u#Kr7&TYcy+jR%zpXA6t##mDEc8OiMnDJuGDkjF0NH^ z*o>=8uqaB7mMNiGmWF!MAb`-Twnhn$$99INqh)BE;VIiBQpUMu<1W`IjS9XIZkN$H z49@fn|48~E4oA2zQ6A|>U%SAz1!{1jTJNH`?r3|NwuZYVhhDUd)R!rfC*b=X_59@g z>-`pLLWTStrR9Fe`fY@1dHS;97a>kP$Y?5?@RKG=b&n|kk8|Wa$60UXJzt;-rSG*| z37#nWyh)lfp+I?Yoa*Jrv}c zPufx@Lj=StG&X}TOS{YXE#Kn;XNl9Eg(078J&6b6!m zGQp09rfDbNr8GBh;Y)PRWUj@E`CM46l8+i2rCUu|izMo$Laa0p#6r(1NqMmBbLlDO zIrOmYh9>$4hQKj1{ zQTkLK5gd#*N}E!dUIi+pTKti2HoW*zgA$w@QC|co10pg3U2@ZzU!?-1Nr(rBQikWcK62)Lt!?PHj{icOv;*!>~;wkTqMN@VjyQYv)+yy!D2S zN`yge0pn`En9W`r)y&hweQ13$*^XaUNz(*nND{5(t)W4h!r^@A`0~7Lm;061zjfK{ z5Nil}@wixrRz^G`qkaSNqgF?-{fHfkCp6_|B0@U`R7wl!pIQKlf-T##;uZl~@Xp-i z{V1=0_#dnM8e6P6$SIx*-CPG(@A#Mbsn722-hF-z>z80#SHE)d{oZzS;QeaO*oe~| z7R&u0Yp|l$7(KE<{~PgufSLazL@hWFuq)8V|M#kIH}*0ZoUDZ*?cRiX<%H5Ap5S#e;YngH3sX6lT7w&2Kwr}fo z+Ogq=drowMbg73&nL`Q`$k2kWxbr_WE&boTJ6QzG&>SrW#%p~#bi$3#LpB^9!BQuo zCb1|T9R+yzWdC!a2YRJJdhDV1$hK7k*e^38^?s>GyCuM&AZSRfH>0Y2>;sVIw88#h%w#?#03sBJ_cX$>oGu&<%P+2t6hEx1enK z9|Ia9ipCF;wkX0>uJa9lKA~w+vWX7-bE-AgNOvUo)>F}C+WH(c|lVMa&lK!RV zOpJ0Coo0dNNqH9Z(92v3<=C-Xp=39q$59T$vkzOqDhu>b&hn=_G8zl0>B{DFft5bE zvMKhO<)i|~Sxj(D?Fi+vJIfWgMj0oGz4l2wd6k#V<4`XI+HvuwP1{)ZFGIA7<=D!t zc-@c29LQDvwcO2dkma&zbbF2DC@WAW4I?EXU;p|1>)asFH_PC=ee;A@z1yv85zgjH z2#rtN9 zWit8jZRdyXPVZr-tA_puK%e`_a-=9^nKcRLru!D>bSKVR{s2(grDQfAPDd-jN8EI;1 zx+xjjiRn3-)hX)PIb#FPw~x7CUaFYQVIDcWZ&hdf8MR+yBOoxCi4U;~IOGV$(cfu= zwCfhVpEch`TWx!>RTYtN!;XlIzn)SV6BXcae|uKAINZB${Y?2DWCX5`cmTU!P$X5< zlC;v(vUE~(fm+Ks14(;_aLN&Bih5GfqeBaQ=5Z zO?UGf&R-xsWDPx8mWiE;nbXhW3GvgJ#8`=mMJ}JW;C?2GsfDaz>BRhk|9}M*e!agR zQGa|$yvwvEWcr)LRdfedade# zOTM7!Y1;yjT4A#hp;jQiY^{$e6V+*j;I{29Ya%3sC3<4D(MRY7pjjJ5!T9^gn25wj zI2rL1J&NcUjy?B^3@~kpmTYj~Urj58m9H#!2J4gFMwB{4?vt!>NS%8FaNeeBD!KbJ zx_|c^vvc?MA1^A!+YoBG2f?X2_G=jkYZ~ve5;%lgY#0e>Vc3J%xAEH&GxZ=L0T5X0 zWQ>#b4V3@*!c%lXdQpk`?>S>L!vTMZB3dEQ_v{`F+2_sebO6JAcHEuz`<-TZ)Rf4K zG8NV~xA-EM+tOFz#SXDicj7Hn% z-p^|(%x^vqW4fuQnY+D_ux54DRq!%Gd?$q6M`|4ewphnH9T}KMtw(>xhD~!20mgmb z`p@w1(SuZuQb|S7hNg$-0GiDb*`s)J`e^2p@N$P4K4x{3U7^ zwy$&#CGHcj<86?(mlzQB80^})4s1g&;G;?YcnEQhpj$%q0^3Wh^8`M|XgocF?foqQ zC}nA{TO=*(a{Xe#&rz6noQl}tAc;K<;5#t~K120%VN_>0=uwVU!RTmm|K5ZEzEJ9} zhz{~O(c$AVrnpr`pl`is=#XXSQ5A7hQ{>iMn(j6Tx9xmYB4X8<89mf1 z*`NMyY#I_HbD75|B7%8dSTYlm8+~dnrleON2%A0D)a+NqkHA2j&Bu3sMwd~9AcT}7 z2T@guZ>o0?(^eV18_~+SWPEMlBk97x3Ug`==&l&0g)|G6cy2_iFP5y_H7;J(?UEazk4|FHcmH3Pf2kxQ?QvycsU-1h&cNS^P}jb;nWT` za%O<_hWAM>Tt)q9M8^Cv2T52WR_^6FxccQE9ZP8mII%oOje1E;>YurXizEDdZlbGp z1|=&A^%$e3eM@!jtT}}el;tu9+S*dF3>Q_s zWFI&I9m&MU@=Dg>q0DfVLmk{E;=WNUQ(R;mBg7Ko&=C>fJf}mj(@(b`cUygdCY-?3 z*|NIXr*u1}!u@1*=uP+N-~m`>J@8MW*HPxNb^5VtEBHwQW#efEa-8lFr$GG3XTNB( zs5vfl(zTnB#4V?skD?GaLjARc2CcS|tOD(c*B>tt-{xaoawxP2N3ISE8()Nzu4^2*Yr{P)mcI1Tv40XzrDSd`gwd z0h|G|JmYRT1+Us3=Re|~<6M&+ig{=1y(W1ud|HnpvCgD2XzlAsOweMkz5J!mF=;v) zN839A^?ugm`X}Rk8>dp|PmKzX5g1WuFNC(@!RIsXBi*tzw_`l}e;5=l-9!XBzKkyH z&atS;j$>}atQQW`PvvP}U|}sw$0VhCB8?oQDIr`v)+gzvG~VgN2uav{Woj;VzoT{f zKi{7x0#IcecLo0gKqerNkYSn7uC^M^vmVaUpx{RamQ|p5iaEJ8?9RzW;yCKOP{`~Htr>gbU8 zgRvJU&=%;c@JxqG-7oPg`-{tMk%77MlfqzeBODt<6f>6{ypJWa8^POFRl!=LH33|P zT){95maGC^4PMAZFmA`xHn-8#C!={9kw@)t-(FOS&hrOR-*W zAMInO65;l8ht|xitdSZ7iokZIpli?y*GveJf)_^2-+vB~rNgL?>PqVZTnUvRV?+bV zQ3Tr6;|kk6R$Z^kzw`82GCvWQh5N(5)gLIcU#NTe>~5W(Q2!}zxYqVt;*Vbb|H5Py zvPpGFxjIPspd@ui9!jiGrs8qY+(Qkk$5XoD#Ts=MV>93T_NMvAPn@WxVwZ-{ZRrxW z|IS@cUn_9UsL|#@N==T_EKa0LM$#JCHx*0rAQJF;-#d?s#y_y0Np0U`&!-T6fX_^z z#}PLg1eaP4Yf3%_E3_K{h0EO?1SOlf$U5mH(fXPTAyD?}a1{9F^O7r~DEyO7m4cv5 zM^yQU9%sR?Ls}%8nWT_Ul&6Kj6`TI0T-V`WLLVsFu@AXR{_^7pocJNBy~(IN@w^xF36BPd>u*3L_rS#H4wHRfGiuOTg1^@{oEr4eupA%z>Xx}PbC4Cn^wRqel_ut;5i7v$wjLIMY6 z*JxX7K5ItE8kR)rhujn~$XXh2ANKDywnXS8!_FoS(k*n^mmcjmOVw-?e_^E71i?~M z1552f0d;!Ms9g$=8OM0^=(!-d9ebRjXbnJ&LoXjY&eZwx;ITkjJ*+yC)G!j}*1+A& z1fv=$2#B73-yw(VV9w!y7_0_EVyFM$J7Fx13QurFdE!k->^Md{88*I8j?zg0c?{@d z-8|;4_YRpDA*gJLB8~9B;HBAA)FlOxiJM1v(z)ml`Hb?m=}ttbJD?Y8zVR=SpHHUu z7=DTwCIKxN1Y7Jj$%yAi?EJX_8O#o*z-o>V#xc!=xyL8apf8TH{~=9*BaDJPPt>=Y6p>PX8syxK6bVmH0LYuerF8dPa# zYSU_FXX|RYsggAJ87lI~AD|(E0lVJX0kcTWGy(v@kKX^O198*57l&%tnKfVx_Oqse z=z~phlMJ`lm~CVmYd#Bv#8|zpY=k1rq@}$4lir|&7V;Jt*%7&mejy1FrT!u2K0yMs zyVD9Tkia-mm3O=+WT{x^Z`s4(;>q`jxYT0{ni$w3m z55?ArrxCM%6tLffhFHuVw26GUovw>|E9+Q7VQYmgfn&^569CxnH8xe@PKl3zXh&~y z{`J}UdMwAQxApZ1o>Kkd@C0UI-<;nwg<4vJGR`!4zw4Q;> z^W7jP7`!@^xX2Tm1rFX0ievKAy?!4Uz(WWO^|X_Uv_qng&ri7M6(EUY2U$18D;2#g zQhc2qGbw+`{rWBb_b-l9lfx+n`8LnwAl7!3ioJUjpGgEYK=J-9bz`svP48EU)CzD@vFM?zlcW&I(w8 zYc7F3Wsj{r7f)9aTp2lnUdT5Ik4>UMBGeCZNL}9r4{Zb+Y@H;PE442rVMZWYVpwFI zSk7eTnD5~ah@qBF6^pf{voVltNV!!)k>@7xh$kb~JPism0lz#+n|476gV>y#d(G`R zzw5ca;}e6|mUclU?t)(jO?!QVRFv(mEBHz|S@YCAJ5*NGidg;eo?B-j%JYEpyo{y@ zCHOlt(dF$>FIP&nVM z(Ji$ak3is~uL=A{+Xv!>52+|dMnt5&NIW?ffMQ}E?czSY1IRrAkBMDg#Cqqe9(k?o zoKZeoYA3z}f-8vfanS{`CCV3b_rjP z(pI@%U{^!-D|V$t;)q9;a{lrFVe{{rr;*?9bG3PdU1in0?4 z=d(@kc!;pJgCH`rjl@>Y`3HZOXGlfv7@~1^mj=E~wrRJl1GULVCaV@OgAYV~?_nL( z?-Evwc}wht80M2!)fcf$s1@Zu%KdIy(v77it}ik;lCr5}*f*$hb4{qry;qViE{Mdc z1lk|jx*2>EO9Y{vqia$(TtZ@O<~-V>T4jeh2)wV%!h|03{J8Sw6h}o#372V3v_CF$ zO!BOBa)5SbJ6NQ#ChW)0J!Ozsi#MVf7MSIRXH`<@!AV0n{Jdei^3GOw%8rq8+bw$X z$Kyz|g8Hv7k`D|0mxkK(rlOC@EeaFvCXAFZo6y%)a*ZZ^$sfD6O&P$vG7;*@tN)8@ zqP~AFn6l3&+mzb+Ma+Xs9Fzt+Qx#)iR2np*ltwvjNW#Olh${gxO5v?Pnz$U)})rb zbo?$cOh(i7q0P?di4@!)YN@qBTXdVM%r>`6-K)L+7&3kIj9D0&no-1R@SFhCS5;ZJ z$P1;m&L=Ps{c-*`MxVy}7^@deS5TSFz{s{bpA_tqQJfmE)kjPk$f9lO|6XY-FV(Ip z^kj^UwI0Lh?^L}|q0Z-foTsj=) zM41@@!3BNg5{}M)Mw})md3_BJP=#J^%6hI;(MU3kO`(K(s$!e&n7Ek%c_U$fzo=2X&YmDMIjUhCVaJ=(A8 z<5aN)h;lG~q0Xnu=6tWiq6qR{@)&+P*7SLGuV01;^7^3kLm6`WJh0q6=^j{GrX0(T z$zP~46~C)IK{EW5?T(>HX>wY+R83Z>7dp@}T6kfFD_IT)O&;tKxJAZcgLpUNX%IFF zWzyTT+oc^xN>Xae6$nX}L7cJJe1NEDEWpUrBr^?#7#5q)$?JNds4^!_9Ku+KpwyH` zLMHu9Kuiynu@a_1t}!p>PVIXD$JX>~+M=b3|KM$jNQy~78%ly}+n^+VV&A_pcikhB z+>)dq8XhTQ)0;T++;Rc}Z!$}S$Xg=OVMui}MRHnpyO(ZY8@*IWmh(LNd7v`SVjdZW zBd+k!cR0=7e-#>2FxI&J%jbKk#@?N@Bf#Zv)x=UD_W3ex0o={9>t`c&8H;xG5XuD0 zOLh6b@=FM1Rl(#ttMOH)Z!sbdk3>l)dz>sLe>5UGFL^^zcQC09TTicdr`vNu?;N7< zSVwBxRzQVO%gwr*5f#q6D^D7H-&chEd z-aYxMO}QPIv~>|%=1h$&(rOS8hpMC1hZvX})Wq6OELd<_xdobZt@5BKxgT}O83|SY z>r~m?Ty+pPtfDKwdKiIXg1dc0v3XuY&kQAL0nl}OKi3PX5xHNe=+<-@OrsivZY%dp ze?kqhD@0%q%SJH9V}R!nT$l$kqW%!Lq9rz{VIN%Z>AE@yue8%WWyWJ2Y(fweVVATc zj3m^`FEi@C)8&=~=7n6H5!*D~LHR5vR+#lT8ZdH*i*U}qH<}gc;&nwNqD+(NVdzbH zJVV+vOK}X5Y9#y_t=Kk|c_()Q-U>u@ISVUbhJKf^K~iRONV>>mQshdtJ$20`>Oejv3p9Tfe$ku zE5^2kO7q0yI@*%sEmoz5-cOC4jh^sCTkKk3txl&=-^`Xq7K`I$_4ZErzxs&qJ9->e z2eqCL@S+tIt|!$a))I56mdri-iW@35BI&{08S^IP{Z$b^XcX%Eh%`$}-*O}UZE8V3 z%xlce6-M6&pk$!TKXYRn2Ol0zzPwpKo*oWPz8=q~9^72q6=wN-VCuS!0hZ{IO9<(8 zmMXDv6ge!A6VLDWP)N2y^C0e-jhF^~We=78p>vQnT89<7a>kOHm+jOetb0N0k>!C3 znL*}62v_H4&5Yf18#wi0takvL+8b6iwmpI8A{*V?UhZ>;(?_ztCFte|L$SoUUEH^vqahHBUN93_>=W0gz8JziTMoG!-d(XDgp{i`}66 ze+sDZRrqaNc&nG+?W8^s;I=_5cpjJqQU%iX|9J*Mf4*?wo>9TRJCU>jHqKK7Ao9&a zq8}&qMau;dRN!J>7VD^KHpDmQ5F=L0rfz*3NMcy$EVlu(D8Rg%PyiJIdxEV@D5>^Q5=}y|9N7qKWR&j`Z zF7gchMT+-x)7NYzN0Z312gl_{P@J)yuP06{;sGfVA$`m|7^if+;lSuYP!vfz$BJ>W z;n3?(HffGu0P;tSSe~ueCTwVd&^YnN|G|OZeT>5eMjDW7$KB6ND6CD{niBytlM^MP%rB5}N-n?dcZxHiI zKXC=nC}@*2LD2b=K_8ol3}n<>(PCvaoBS*&kL}~ffsP4 zsPv^-l&E*pHX=yET(Xe)Wvrw8j&}^>jaWjw*&)Xrh@U$Nm0I%6H3l=kGYyoUz};0* zE`wDr^54PmcG3SSQqES%ywI63z|UyJA0jsWQSY+`)By(CxL3V_pn6riZ=B9qT5y)q zbm*0KH_&WHk7(AmtO^@Nvoe|7qUGpBa@^^cWYMu;iER3QRu6;~=?6W4RRZOyjgbdu zRk0oPt!6L?FBtw~QwrqNFdO7nZt(G*&Kh;0A-3U3Cc5F$lwk3MuS#>9@UIZL#N%R+ z#gattP~r;DMBp%+?D37e_TwkThEgc#x;K+*XtD@JhJ%$v)4;3tVhmU5f2euUgHyJE zgSDQB%~d0S;gqmDW}ztN*4q$_+qSDfeGkrn5Z8f2!!}+H;YHKfD%XWKL&;lkA&(|qq}2il@zP9% zJI*H7Y|lz1Z7V^It=1u|qoZ;+FqJOcOoWk8s~s5B z8dPTkZtrY_o9)mpJ%-!er(gPNn%sq|wbJAXVwFd!(p+q6?Qt*zr|NH8Hm%?;B8>x; z-TLZe6VpcQwkH=)iL8RR`@re`YpzT5s|QlVLn?j3#bWA)*|6{6x(?IAQ@L!NFTP^M zj!jxC{{jYx{mT{&#_c9rfYB+JWd)HLU8&x&wvPOx(&U|N{w=VsmzOzQc`BLZngcX% z&V*E*S(50Ar+6+x53LH{HQ5wP{X!l^V%S3Z0Ctt{@cZR;sOtCDUl>dIOti+S1m8Q5 z`>6seV%WTM%01PB4fR!XuKt5o$fQCf;?~$ajNw?u7zgZGhAN4BAA{^vJr5dXd#xgEiM3oI%+4bl@pe>& zliZbSpffiRIreZf#X_o&8j|(opQl$p2eP-Gs1gz2LdkkNGnO>4ml72x&!$G%FS1#h z=rrU!xlf5lxQ)jHx2M+^mM^1spW6qmBdFhAJ%fufu0)yUmP7%j%2^Ktax^8>%mmv< zTgD)Ze-wA(GPRARHKZ^R6z~e66=JG_lzxd42f|b|_?Qq{lbV5tm`o*$8K;JDz^EY3 z%R(o))K|KAfwEgryz=B&aegTD0Pf3hYfzg!(zAHePBv(?Ft0l#Pln@{dZ|HYZ`{-v zX+KG&qrJs==N{i*Go)b9b7x6_JJ6wM#0|kzu%67063#kj^XE8)Cj+v%k$2v(!Klp{tp(Jk6~x@KgeJ@!=2HM)EJDgwrdYf$!Vid|Rm4IJ zR*KsNE;iI1|9<`x^rt%!yW~HN|DR;LqzwJy|6^yRy981wxlU#pyGOo=M5)T;vGOvX zK>(f2z>hbL|CbT&QQNXRVn_1f*!MHgS7TR@a=YFJL(>?_qKCUC4>(FIJO?FHWbR6& zj8|#g74!EM?Q`fe2`i>op=}!|JO>yhu$YIM&%#kL$wzpHYe>yAj30>BM@k&?nX6tz zOB^k1rH6+vDJiMRWbfqWM~i9r6iCNol0}nbn^KaSjw0003qqN5|=TmVzGuWhzO&&Kt5D+NA_>jWJ!5i_K5;6Ny;Od#VdkJ^~+ z{*XzFfHnqbz-!?UAA*GgrDcAAxDBkwI@^2y;Qg-7;sk!fSdRSjSG&3)bZL7n%6v;S7`|Qx)c#bPQ0Qr;G{UrlvwZQY<3m)R4t>;$IAB z89=$H(bV!kAhA4rcg1~pwQ+O1olU*9xlPUHReC%oe8hUTgeri}{3rUTgijt+l2OQE zuV8K9$emMIcZ9r11|2$8$tg5G+3hAF=i{^h5F2v!SEFqK_IcMy>Q)<}$lP?@a@p#< z7%j>kQ8jxpZVJkd|1w1vMg@OodLv@$V{^(XeaRvin9lNYE|Z!rK56c$S#8D_ouAvj zb#2<6E(3B+lCG(eLOt(0-8yyy9HSoPsc9i2J(TtviW`lsmyhaS{EZLd6us}#6qiN7 zF3W-l_+~OF$mBiub5o1FB=KE9h>{n!cfeLd`8?gy_baL|#G+(}Jb=M$b*kHvu+px1 zk-n}$wuI|axYW3l6i$iMY>2pfuOU}fG2E({S>G8(1$Nv)1_%NN@^}P(Wj- zn}dbXcXaA?PMwTwk3h2yzc_Vp#{I#-SRhuMu|@f7=i7ha?tXVWKQ!}g)wQ|f{ch`T z$*4pva~ZL~%9;{r0v+i9d={!ren88SWYEK>SlU_>l%LY8j_4osmPyR*3fbzs`=Ni{ zBU3wf<`}(07J+g5cxZKcI0eOz0iB_rJ8~9P^&Y+}Am{GlcR7f+BKLPc@F?0hV8Q|` z-Oj!XU&4FGmOJ;%_|pAU4+<`2){o;I7wX*Yy|yELzY{YZG00JFR_@3U93L}s+z2E# zXP))^*b?P>Pi+Q2nkbPO(`j6@FFUEmuGn`Md@O0%IE9vmYX2lcfEpm5wi zo5nc7rQnZQwy+GJQQcOCE*`V*R&deoZWW|%-oJHDqJuvJ}> z_Hdfh4+82MvvcA9ioDTMj!($Q{ZHa;YMSOhU!9U_RaT02R&r9FR{H<=>+k;zDqDxZ z>_^5N!Nc+(Y*m|Oc4^GRm3dT>hS?vs=?E}PLhJUmt8$x)Kb)nT@KluLd_9fyjO0wE z%$Q|feqQVBI8H49QMtc)CKY<0l4a{5&_E}n0u$}O=pNZOx;yqq?Z|%mgukExH2bdu z4YWKB#{2$&Y}qT2jdrP5{LL#+x)?lm=90u@jt;S(Hp?VxCG%k_Z zvACiHW{3b?T1KY;o@lVZceL+lYF!8oAc;qiQ+ZGV77TcwP)4ydp?zdX2|BOjq=lsY za0Pls*ZpRvN2-3fIOGvyK^;b6i3=*;E|#7LKx@hwMb20m83$ZD0W;*!>_5i$t3A1; zKNol)ah#8kzn~K1t9yHL{5js0EAh4)YAc=PS}#`p2EzL|*#1Ex2$H;p0Z?hbppVJ< zAhPumI;_B>MY&5%t}^=aWyMYa+*4;iO1Uq7)b%RavZ&ugyuQM9o{!+di3`!+$|99q zgiNVW;tKACzDr62UmzKz__IR#V_U%v?tk*$;fqg^-lrf}gw}52aw+Nd-7X@_!)K(Y z#up&M46r^U&y`$f@4F)YriibP=%;>Bb;}ra-E{b|s3FVuf)+?-qVq5)%DfZkhiWeB zC!S%KPBCFY6_-z&`n_`m0Mjgi>kj)Ca8s3=v=(LfFSVpAHXE{q1P?X!e4A_=YoGP%2m^XzqDF| z>^TPE%c|p2ay&`u^J^3R%CjOH&937C3?%nOq?D0o=#u@)C@gZOGeTA?V)T(4D%B6A z`X6ZJw2h8A)>)YlSP@*$!#@`5sEoHAjU)mGZA$su_BdI3qvJ~wp z^hl9w40F8CZ@jb`){yAfO}$ZHf4y!^0_Attv6ub3PaT^%9So#*;2l(%Uuf{z)maW_|P0C#h+}!$<6n>K8 z6TG$lH8riE7%>nkLUL{`T=7dmH>>%Zm54h{EZ(tU8fyc2=d3~de;~*1Xk0rsqC;hU zOwDLI#q={YH>V@NMLJ1lly;0>uRr&mr${lRkhl*wQ=F2&@;_B|9Ipm%Qu5~2V9@B(D7n8@|Iw>1g32s_#L1Ieo(bq69d-AgtK@wOtZa?c~Z5-kplC_B(mJg>SBY!OnEtzbH`i)Y0t zlA0wjw3WFM>j(ig;pCL=SW4?@bpX?%4mugf79LWz`rgi?g9OyO1 z(0Q$6!&R*}gszl=zfj{3in&_+_n9CRsAg4RYXCy0PkaBCdSjsHu^yHq-J^hYIuHe7 zkUkmLIT?x};&hJ75S=fjOy2X`(d0=_^ZlRsid&iQZi9B2d5jLL z5`NV6&d_B%lO+|$Fe(>*wMU@PZ5Xk7HOEpq^T&Cz6R;-vdU5iD6qRY9rZiSF6CSU5 zybNQcR7f$-wZD_(UW=ssMF49E(}7i1`;sY*FZh^8YY?5Z_Twni==X!V+wL2@f50E4 zn|fvv~ADvOl+b{OTuaZd<1>orfLX}tzZe6g;j{lI4qsY+IfZ6Cr6D2|A9 zdCWz)Yk10yYPg(n!A}3;FC8VMJy?wxBhx*irztsvOTM23#gVzjJ*e>)!ryS64lrVI z8NTX*!`4aqj&P$iNiSMJ5c|mW4v3JO9MZ?ry-VG=XI58UV)*srl?JZ==rC_;8YPx) zVpVd7lBmy=s})u2wesh+*xR+%rf~^6w3hrzPyC7qRTl5!v10EYUk(1Nd@O@9b z>lo+PbQ_Yp6txDFZcDadeSuhobKIM0zSo=!M3+XZiX8)Bg^N^WH!)^Wi~BhrAYeE8 zgn0~*KaqXC?-1AfvsEVgUR|a0Qz`TMr3#lRo3f!|i`vQ@ZxAshq%r_|j;cXrnr~gI z=lIuOY2I}#|Aia{t|Txuq%N;3b>+5Kz?*LBRy?1LBgk5?3b8=%a}n7{RadNPpc10$7Obii9PFxKWgBw$0_n?Njlf^lzoJDwWClsqBQ>Nt)pez( zVUM7(cBDzF6?@ytp=zMUD?s~c>MzEG;97DlQl}N57T%3*-8E~St)e#(qSB>9T*&)r zv01(Gxcm`@o7)IHbzMF-(5$hx zqO}1!T=i8bGL4^03!p8augj|CT2%8@QUiyppl>6f7t#|)s?zJ) z6ukC#s&}JzvsT7KSI(cx16778_7EE$bN_rq;%T0}u0hK8#`STEl;V|+tH7&)T$#zv z3DuMnFwq~IoY*qxyrJF}jSfEqaM=kO!%ds51J+i&W@t}$c~ z<1>nVDzpXZIj=TpOvP-#UP1!a)QN!&m(FJ3mSylq4_9*dP*Hagxpy%hO=m`w?Jkla zIkuBk^hNGOM^=?9vwr-!-%?52jNOI4)q)tG0tc)Lyae*LBbFshQBo$_j=9 zk_A|SQj!}Ug6E8leSO2gJSbx9J78%0?+igWcq z1MnXR4NL<={}Jz@mnnLd2<{-7Nr%+L0dy+#WL>-a@ADvkj3?Dp$?~-%4O%8Gx(9rN z7}*Vu{vjYlP+qwEbo+MSh-_ed2#@Y1mXzI{m8xia9|OewcbA#Q;vmoJ#V(OchSXbu zXAO_$K+hI*Z=7jE#;^fg2Of4V=}-qJ;hw{^Hh(uOjhBds=7RvV9Hfr3`Chp^wc zb%1V!Ou3Y!n9(0l5urSnCfEN&9aUTFEk>jt{Bgf_A19EOq*6m!(4f%NnEDo+3j=~M zT3y_l)?z8AHk;=@d8Na&V!I1M&T;p*?H%X232?2!aI*e+g_)ESrGB39T2_=Oh;aW9 zC9X13jnu(=8)J9}Lt11sA~Fap&ZMW|y=@J8=66yS4X8wWx-tk~DIa^Zf^^aET;fbL zLdgJwYPZ=H8u4lJ6w#)w(NmfVW2X*=!d6Sx#$_}Ci~ozLb6^f7+M0H3+qP|-=)|^d zJ2|m!+qP}nwr%^%y>Hcb=O;|9soA?%_tWa-eGFwh0&et@eUHm)Mx#my0!$;2rW7#7VWAZf0M1gH+eh*f5RU)9kG>@+#TK3PQdaL<1fpt$;$B#;p48;zq!1Cp!$tI zzti9WHcIQpSIW(55B`?o2kl+Jn__i48{WFta{L@k%ibSH?k4-( z1>|>FD_Xa+X%nX7jopLk&e)wC!C-yDKTDoP66zYt8o3G(D-{E68`^qLyvrg!16mHw zC~CwG&rD#ZQ57E?XY-bnt)7Ed6$|t^fy<@)yB&|8!Qa5*RzXp%1a6ty^3s+A3@}~b z_xMhd9%ji#De@SX&=UHF+*j3{7U!%@QI(a6b3c3|>g%%+QQ5yw&I^fJ0m0%SswMe4 zR%^&*8e|X;LFYOc9&EGd-1tD>{WP~yqN(|QtUWx~3y6+dhDo4C1p^hV9fHw)v!Mue zb5_RGLmZ!3X7M$=vUT6hm9_9MKq zd{RWc2Fg)CwHQ*v*-p2;5}`@;vBUM zbe`aCYep6}!k%c4XMW^ft7LoZAJvH*NMjxbJ%S{Ut@^MAx-IkMv*JW5i{j z3s76vAe$`d;(aphbB@&#Vh?^!saU)e@_$O(e>ekCvXhbYGSV~>)3g7lx~)107W8kt z&c<10Xz*Pd+VXn5z6{9*BfyHMxu4yQ6%CQTwcVYGycM}q{sAm)9t-o(CU&2|&BB1I z$3aE>oNN)R1yjdvf6shFH`M62pnt(z$fmANuyR%W*Gvh38^f2Vi3=A{M#NSeH^RUs0TIDCfr+EXHSp1XMdEVXMN zJJkJ3aB-}vY(!b$0K6tmp+K;MBIDm=GVGm|nI$yRQMKE*vrMO9~e zWe#F)@e> zWCdes{ONB6bvY7lGkbl+x7?~}Pa&d(FQ`@vRx2067CD|@tYr{5p-&%&2WOCs5ZcZK zS|fo7BlrGUZb2vI`r(MG&`wTgurxL?BC#1wqvy^%7&;P6n{#tACxEZx}enEM8+Mrm2tRiRR%lO%3zu;6B2tn!|@7I z{1;nGIY?3yyJqurydfI7n5NnD2sXm1TXZZH5Ki)|n@XQ$n7kdg#PiFE?Y<8zJyhmTo};03p9r1J?m zNi@t+myk4Vw+^d%-4!28fi~$@v7*ZS*gBZ46fioazYvcP-SEHAs2G*iW%vB{XMnwK zu=ioRyniS=bccveM`Hm}I_?WL_Va|iH$68#RH||pqdE1cujq(9K6Xhd65+-q=uK#k897Kg;v=# zuB@bpoC)dD5$=akI;+AM9||m;TqK_1M*XD~$m`nRYMUC?$l?IBY|+Wd?WCx92s9}B?9Mi(+C=c_ah7{1V~fJ}YL&$91{Ji*J#t;+ zeZhTJ)<~3}_{}D)gn#3WbDeR;scgAetU%T5l*tZn=mJ$RV}M#=GWg)plzh{q(|BUh z9)G)G1}!*_PmcgL)N)-PK>I_pCabzgIFqImOQZZrwo`hRd-0R34f-@myXaY=+)xV`CuY z;O0mFk(1#c6h)MeCI%-!Lf3}L0FaRd@lhH+Sm|C_ivTp;2+cQ1D@GD{Hn>Sm5LUx; zP%YBz5eXNx$#-DXowgmD@hb_2w3k{oc)^?zCLJ-yBb`~!qJ3*4Bp0G#DBhGUIJ-|Z zV*4uPvEz)6+4Jne+(dSVIqU8M4F`QxwrK^6_G#I_<&1wzDu}#!b+UrY1?8=yOQd<_ zSVAdZR|wDT>l)wy^p~&2Se`A%k7_C9GZ>|^^Qk@n$4FM76NWsv9~+xCjgb-Q{n#+U ziSU-$0=_OqGj0p_6fd-DpBfRi{QaSW)MUa=nlEKC*5Kd=$pi8`jCW)*a1^Hb*>3)-V;2RwjHhXbT!mSa3ggb!ibA-v_$>)w z_>!7$#*N>4oEz?3gF>PXk zrU-K#M4y{Bn#JDj|1zw~ct_dkVA8Usy&!lwWb$$4I_-JlY05Y4nf7hu^Xto)d}usWFy#BN82g z5lTMdUCD)fLS0hQNBr!c-fN=4G>GLy_+BMZB�^wsVJ#=5x4m1ECuD4fzB?+N5~2 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

2NmC9N{f_srP@KJ?2J&*_G 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 diff --git a/08-agents/08-09/config b/08-agents/08-09/config deleted file mode 100644 index e69de29..0000000 diff --git a/08-agents/08-09/config (2).json b/08-agents/08-09/config (2).json deleted file mode 100644 index 30b223d..0000000 --- a/08-agents/08-09/config (2).json +++ /dev/null @@ -1 +0,0 @@ -2026-05-15T09:39:42+02:00 \ No newline at end of file diff --git a/08-agents/08-09/config (5).json b/08-agents/08-09/config (5).json deleted file mode 100644 index 9e26dfe..0000000 --- a/08-agents/08-09/config (5).json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/08-agents/08-09/description b/08-agents/08-09/description deleted file mode 100644 index 0feed0d..0000000 --- a/08-agents/08-09/description +++ /dev/null @@ -1,37 +0,0 @@ -AGENT_GITHUB_COPILOT_INVOCATIONS_ENDPOINT="https://ai-account-l6lgrnn3i6fcs.services.ai.azure.com/api/projects/ai-project-foundry-sample-clean/agents/github-copilot-invocations/versions/6" -AGENT_GITHUB_COPILOT_INVOCATIONS_INVOCATIONS_ENDPOINT="https://ai-account-l6lgrnn3i6fcs.services.ai.azure.com/api/projects/ai-project-foundry-sample-clean/agents/github-copilot-invocations/endpoint/protocols/invocations?api-version=2025-11-15-preview" -AGENT_GITHUB_COPILOT_INVOCATIONS_NAME="github-copilot-invocations" -AGENT_GITHUB_COPILOT_INVOCATIONS_VERSION=6 -AI_PROJECT_CONNECTION_IDS_JSON="[]" -AI_PROJECT_DEPLOYMENTS="[{\\\"name\\\":\\\"gpt-5.4-mini\\\",\\\"model\\\":{\\\"name\\\":\\\"gpt-5.4-mini\\\",\\\"format\\\":\\\"OpenAI\\\",\\\"version\\\":\\\"2026-03-17\\\"},\\\"sku\\\":{\\\"name\\\":\\\"GlobalStandard\\\",\\\"capacity\\\":100}}]" -APPLICATIONINSIGHTS_CONNECTION_STRING="InstrumentationKey=b04e2853-267b-4ef1-bc48-b99c7cb48e80;IngestionEndpoint=https://swedencentral-0.in.applicationinsights.azure.com/;LiveEndpoint=https://swedencentral.livediagnostics.monitor.azure.com/;ApplicationId=4bc71e80-bbb6-4678-9152-815a1a24c549" -APPLICATIONINSIGHTS_RESOURCE_ID="/subscriptions/0e3e441e-f1a7-45da-a3ad-b9299a6b8c9c/resourceGroups/rg-foundry-sample-clean/providers/Microsoft.Insights/components/appi-l6lgrnn3i6fcs" -AZURE_AI_ACCOUNT_ID="/subscriptions/0e3e441e-f1a7-45da-a3ad-b9299a6b8c9c/resourceGroups/rg-foundry-sample-clean/providers/Microsoft.CognitiveServices/accounts/ai-account-l6lgrnn3i6fcs" -AZURE_AI_ACCOUNT_NAME="ai-account-l6lgrnn3i6fcs" -AZURE_AI_FOUNDRY_PROJECT_ID="/subscriptions/0e3e441e-f1a7-45da-a3ad-b9299a6b8c9c/resourceGroups/rg-foundry-sample-clean/providers/Microsoft.CognitiveServices/accounts/ai-account-l6lgrnn3i6fcs/projects/ai-project-foundry-sample-clean" -AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" -AZURE_AI_PROJECT_ACR_CONNECTION_NAME="acr-bbc2df3sut5eo" -AZURE_AI_PROJECT_ENDPOINT="https://ai-account-l6lgrnn3i6fcs.services.ai.azure.com/api/projects/ai-project-foundry-sample-clean" -AZURE_AI_PROJECT_ID="/subscriptions/0e3e441e-f1a7-45da-a3ad-b9299a6b8c9c/resourceGroups/rg-foundry-sample-clean/providers/Microsoft.CognitiveServices/accounts/ai-account-l6lgrnn3i6fcs/projects/ai-project-foundry-sample-clean" -AZURE_AI_PROJECT_NAME="ai-project-foundry-sample-clean" -AZURE_AI_SEARCH_CONNECTION_NAME="" -AZURE_AI_SEARCH_SERVICE_NAME="" -AZURE_CLIENT_ID="26fa5676-4572-415a-a521-ed201398e7aa" -AZURE_CONTAINER_REGISTRY_ENDPOINT="crl6lgrnn3i6fcs.azurecr.io" -AZURE_ENV_NAME="foundry-sample-clean" -AZURE_LOCATION="swedencentral" -AZURE_OPENAI_ENDPOINT="https://ai-account-l6lgrnn3i6fcs.openai.azure.com/" -AZURE_RESOURCE_GROUP="rg-foundry-sample-clean" -AZURE_STORAGE_ACCOUNT_NAME="" -AZURE_STORAGE_CONNECTION_NAME="" -AZURE_SUBSCRIPTION_ID="0e3e441e-f1a7-45da-a3ad-b9299a6b8c9c" -AZURE_TENANT_ID="2311ee9d-3e0d-432e-a834-0abb0a1d3979" -BING_CUSTOM_GROUNDING_CONNECTION_ID="" -BING_CUSTOM_GROUNDING_CONNECTION_NAME="" -BING_CUSTOM_GROUNDING_NAME="" -BING_GROUNDING_CONNECTION_ID="" -BING_GROUNDING_CONNECTION_NAME="" -BING_GROUNDING_RESOURCE_NAME="" -ENABLE_CAPABILITY_HOST="false" -ENABLE_HOSTED_AGENTS="true" -USE_EXISTING_AI_PROJECT="false" diff --git a/08-agents/08-09/download (1) b/08-agents/08-09/download (1) deleted file mode 100644 index 5616643..0000000 --- a/08-agents/08-09/download (1) +++ /dev/null @@ -1 +0,0 @@ -{"version":1,"defaultEnvironment":"foundry-sample-clean"} \ No newline at end of file diff --git a/08-agents/08-09/download (12) b/08-agents/08-09/download (12) deleted file mode 100644 index 99a8a1a..0000000 --- a/08-agents/08-09/download (12) +++ /dev/null @@ -1,72 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", - "contentVersion": "1.0.0.0", - "parameters": { - "resourceGroupName": { - "value": "${AZURE_RESOURCE_GROUP}" - }, - "environmentName": { - "value": "${AZURE_ENV_NAME}" - }, - "location": { - "value": "${AZURE_LOCATION}" - }, - "aiFoundryResourceName": { - "value": "${AZURE_AI_ACCOUNT_NAME}" - }, - "aiFoundryProjectName": { - "value": "${AZURE_AI_PROJECT_NAME}" - }, - "aiDeploymentsLocation": { - "value": "${AZURE_LOCATION}" - }, - "principalId": { - "value": "${AZURE_PRINCIPAL_ID}" - }, - "principalType": { - "value": "${AZURE_PRINCIPAL_TYPE}" - }, - "aiProjectDeploymentsJson": { - "value": "${AI_PROJECT_DEPLOYMENTS=[]}" - }, - "aiProjectConnectionsJson": { - "value": "${AI_PROJECT_CONNECTIONS=[]}" - }, - "aiProjectConnectionCredentialsJson": { - "value": "${AI_PROJECT_CONNECTION_CREDENTIALS}" - }, - "aiProjectDependentResourcesJson": { - "value": "${AI_PROJECT_DEPENDENT_RESOURCES=[]}" - }, - "enableMonitoring": { - "value": "${ENABLE_MONITORING=true}" - }, - "enableHostedAgents": { - "value": "${ENABLE_HOSTED_AGENTS=false}" - }, - "enableCapabilityHost": { - "value": "${ENABLE_CAPABILITY_HOST=true}" - }, - "useExistingAiProject": { - "value": "${USE_EXISTING_AI_PROJECT=false}" - }, - "existingContainerRegistryResourceId": { - "value": "${AZURE_CONTAINER_REGISTRY_RESOURCE_ID=}" - }, - "existingContainerRegistryEndpoint": { - "value": "${AZURE_CONTAINER_REGISTRY_ENDPOINT=}" - }, - "existingAcrConnectionName": { - "value": "${AZURE_AI_PROJECT_ACR_CONNECTION_NAME=}" - }, - "existingApplicationInsightsConnectionString": { - "value": "${APPLICATIONINSIGHTS_CONNECTION_STRING=}" - }, - "existingApplicationInsightsResourceId": { - "value": "${APPLICATIONINSIGHTS_RESOURCE_ID=}" - }, - "existingAppInsightsConnectionName": { - "value": "${APPLICATIONINSIGHTS_CONNECTION_NAME=}" - } - } -} diff --git a/08-agents/08-09/download (3) b/08-agents/08-09/download (3) deleted file mode 100644 index 218f8b0..0000000 --- a/08-agents/08-09/download (3) +++ /dev/null @@ -1,2 +0,0 @@ -# .azure is not intended to be committed -* \ No newline at end of file diff --git a/08-agents/08-09/download (6) b/08-agents/08-09/download (6) deleted file mode 100644 index dc3dd8b..0000000 --- a/08-agents/08-09/download (6) +++ /dev/null @@ -1,37 +0,0 @@ -AGENT_GITHUB_COPILOT_INVOCATIONS_ENDPOINT="https://noc-foundry-sweden.services.ai.azure.com/api/projects/proj-default/agents/github-copilot-invocations/versions/3" -AGENT_GITHUB_COPILOT_INVOCATIONS_INVOCATIONS_ENDPOINT="https://noc-foundry-sweden.services.ai.azure.com/api/projects/proj-default/agents/github-copilot-invocations/endpoint/protocols/invocations?api-version=2025-11-15-preview" -AGENT_GITHUB_COPILOT_INVOCATIONS_NAME="github-copilot-invocations" -AGENT_GITHUB_COPILOT_INVOCATIONS_VERSION=3 -AI_PROJECT_CONNECTION_IDS_JSON="[]" -AI_PROJECT_DEPLOYMENTS="[{\\\"name\\\":\\\"gpt-5.4\\\",\\\"model\\\":{\\\"name\\\":\\\"gpt-5.4\\\",\\\"format\\\":\\\"OpenAI\\\",\\\"version\\\":\\\"2026-03-05\\\"},\\\"sku\\\":{\\\"name\\\":\\\"GlobalStandard\\\",\\\"capacity\\\":10}}]" -APPLICATIONINSIGHTS_CONNECTION_STRING="" -APPLICATIONINSIGHTS_RESOURCE_ID="" -AZURE_AI_ACCOUNT_ID="/subscriptions/0e3e441e-f1a7-45da-a3ad-b9299a6b8c9c/resourceGroups/demo-sweden/providers/Microsoft.CognitiveServices/accounts/noc-foundry-sweden" -AZURE_AI_ACCOUNT_NAME="noc-foundry-sweden" -AZURE_AI_FOUNDRY_PROJECT_ID="/subscriptions/0e3e441e-f1a7-45da-a3ad-b9299a6b8c9c/resourceGroups/demo-sweden/providers/Microsoft.CognitiveServices/accounts/noc-foundry-sweden/projects/proj-default" -AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-5.4" -AZURE_AI_PROJECT_ACR_CONNECTION_NAME="acr-3lke24wrqmzas" -AZURE_AI_PROJECT_ENDPOINT="https://noc-foundry-sweden.services.ai.azure.com/api/projects/proj-default" -AZURE_AI_PROJECT_ID="/subscriptions/0e3e441e-f1a7-45da-a3ad-b9299a6b8c9c/resourceGroups/demo-sweden/providers/Microsoft.CognitiveServices/accounts/noc-foundry-sweden/projects/proj-default" -AZURE_AI_PROJECT_NAME="proj-default" -AZURE_AI_SEARCH_CONNECTION_NAME="" -AZURE_AI_SEARCH_SERVICE_NAME="" -AZURE_CONTAINER_REGISTRY_ENDPOINT="cr3lke24wrqmzas.azurecr.io" -AZURE_ENV_NAME="foundry-sample-dev" -AZURE_LOCATION="swedencentral" -AZURE_OPENAI_ENDPOINT="https://noc-foundry-sweden.openai.azure.com/" -AZURE_RESOURCE_GROUP="demo-sweden" -AZURE_STORAGE_ACCOUNT_NAME="" -AZURE_STORAGE_CONNECTION_NAME="" -AZURE_SUBSCRIPTION_ID="0e3e441e-f1a7-45da-a3ad-b9299a6b8c9c" -AZURE_TENANT_ID="2311ee9d-3e0d-432e-a834-0abb0a1d3979" -BING_CUSTOM_GROUNDING_CONNECTION_ID="" -BING_CUSTOM_GROUNDING_CONNECTION_NAME="" -BING_CUSTOM_GROUNDING_NAME="" -BING_GROUNDING_CONNECTION_ID="" -BING_GROUNDING_CONNECTION_NAME="" -BING_GROUNDING_RESOURCE_NAME="" -ENABLE_CAPABILITY_HOST="false" -ENABLE_HOSTED_AGENTS="true" -FOUNDRY_PROJECT_ENDPOINT="https://noc-foundry-sweden.services.ai.azure.com/api/projects/proj-default" -USE_EXISTING_AI_PROJECT="true" diff --git a/08-agents/08-09/env (4).lock b/08-agents/08-09/env (4).lock deleted file mode 100644 index e69de29..0000000 diff --git a/08-agents/08-09/exclude b/08-agents/08-09/exclude deleted file mode 100644 index b870d82..0000000 --- a/08-agents/08-09/exclude +++ /dev/null @@ -1 +0,0 @@ -ref: refs/heads/main diff --git a/08-agents/08-09/fsmonitor-watchman.sample b/08-agents/08-09/fsmonitor-watchman.sample deleted file mode 100644 index f4349dd..0000000 --- a/08-agents/08-09/fsmonitor-watchman.sample +++ /dev/null @@ -1,2 +0,0 @@ -# pack-refs with: peeled fully-peeled sorted -114f0012b64de74ca55c772f4a4b91cf345d30a3 refs/remotes/origin/main diff --git a/08-agents/08-09/loganalytics.bicep b/08-agents/08-09/loganalytics.bicep deleted file mode 100644 index c4d426b..0000000 --- a/08-agents/08-09/loganalytics.bicep +++ /dev/null @@ -1,128 +0,0 @@ -#!/bin/sh -# -# An example hook script to block unannotated tags from entering. -# Called by "git receive-pack" with arguments: refname sha1-old sha1-new -# -# To enable this hook, rename this file to "update". -# -# Config -# ------ -# hooks.allowunannotated -# This boolean sets whether unannotated tags will be allowed into the -# repository. By default they won't be. -# hooks.allowdeletetag -# This boolean sets whether deleting tags will be allowed in the -# repository. By default they won't be. -# hooks.allowmodifytag -# This boolean sets whether a tag may be modified after creation. By default -# it won't be. -# hooks.allowdeletebranch -# This boolean sets whether deleting branches will be allowed in the -# repository. By default they won't be. -# hooks.denycreatebranch -# This boolean sets whether remotely creating branches will be denied -# in the repository. By default this is allowed. -# - -# --- Command line -refname="$1" -oldrev="$2" -newrev="$3" - -# --- Safety check -if [ -z "$GIT_DIR" ]; then - echo "Don't run this script from the command line." >&2 - echo " (if you want, you could supply GIT_DIR then run" >&2 - echo " $0 )" >&2 - exit 1 -fi - -if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then - echo "usage: $0 " >&2 - exit 1 -fi - -# --- Config -allowunannotated=$(git config --type=bool hooks.allowunannotated) -allowdeletebranch=$(git config --type=bool hooks.allowdeletebranch) -denycreatebranch=$(git config --type=bool hooks.denycreatebranch) -allowdeletetag=$(git config --type=bool hooks.allowdeletetag) -allowmodifytag=$(git config --type=bool hooks.allowmodifytag) - -# check for no description -projectdesc=$(sed -e '1q' "$GIT_DIR/description") -case "$projectdesc" in -"Unnamed repository"* | "") - echo "*** Project description file hasn't been set" >&2 - exit 1 - ;; -esac - -# --- Check types -# if $newrev is 0000...0000, it's a commit to delete a ref. -zero=$(git hash-object --stdin &2 - echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2 - exit 1 - fi - ;; - refs/tags/*,delete) - # delete tag - if [ "$allowdeletetag" != "true" ]; then - echo "*** Deleting a tag is not allowed in this repository" >&2 - exit 1 - fi - ;; - refs/tags/*,tag) - # annotated tag - if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1 - then - echo "*** Tag '$refname' already exists." >&2 - echo "*** Modifying a tag is not allowed in this repository." >&2 - exit 1 - fi - ;; - refs/heads/*,commit) - # branch - if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then - echo "*** Creating a branch is not allowed in this repository" >&2 - exit 1 - fi - ;; - refs/heads/*,delete) - # delete branch - if [ "$allowdeletebranch" != "true" ]; then - echo "*** Deleting a branch is not allowed in this repository" >&2 - exit 1 - fi - ;; - refs/remotes/*,commit) - # tracking branch - ;; - refs/remotes/*,delete) - # delete tracking branch - if [ "$allowdeletebranch" != "true" ]; then - echo "*** Deleting a tracking branch is not allowed in this repository" >&2 - exit 1 - fi - ;; - *) - # Anything else (is there anything else?) - echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2 - exit 1 - ;; -esac - -# --- Finished -exit 0 diff --git a/08-agents/08-09/main b/08-agents/08-09/main deleted file mode 100644 index 23e856f..0000000 --- a/08-agents/08-09/main +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/perl - -use strict; -use warnings; -use IPC::Open2; - -# An example hook script to integrate Watchman -# (https://facebook.github.io/watchman/) with git to speed up detecting -# new and modified files. -# -# The hook is passed a version (currently 2) and last update token -# formatted as a string and outputs to stdout a new update token and -# all files that have been modified since the update token. Paths must -# be relative to the root of the working tree and separated by a single NUL. -# -# To enable this hook, rename this file to "query-watchman" and set -# 'git config core.fsmonitor .git/hooks/query-watchman' -# -my ($version, $last_update_token) = @ARGV; - -# Uncomment for debugging -# print STDERR "$0 $version $last_update_token\n"; - -# Check the hook interface version -if ($version ne 2) { - die "Unsupported query-fsmonitor hook version '$version'.\n" . - "Falling back to scanning...\n"; -} - -my $git_work_tree = get_working_dir(); - -my $retry = 1; - -my $json_pkg; -eval { - require JSON::XS; - $json_pkg = "JSON::XS"; - 1; -} or do { - require JSON::PP; - $json_pkg = "JSON::PP"; -}; - -launch_watchman(); - -sub launch_watchman { - my $o = watchman_query(); - if (is_work_tree_watched($o)) { - output_result($o->{clock}, @{$o->{files}}); - } -} - -sub output_result { - my ($clockid, @files) = @_; - - # Uncomment for debugging watchman output - # open (my $fh, ">", ".git/watchman-output.out"); - # binmode $fh, ":utf8"; - # print $fh "$clockid\n@files\n"; - # close $fh; - - binmode STDOUT, ":utf8"; - print $clockid; - print "\0"; - local $, = "\0"; - print @files; -} - -sub watchman_clock { - my $response = qx/watchman clock "$git_work_tree"/; - die "Failed to get clock id on '$git_work_tree'.\n" . - "Falling back to scanning...\n" if $? != 0; - - return $json_pkg->new->utf8->decode($response); -} - -sub watchman_query { - my $pid = open2(\*CHLD_OUT, \*CHLD_IN, 'watchman -j --no-pretty') - or die "open2() failed: $!\n" . - "Falling back to scanning...\n"; - - # In the query expression below we're asking for names of files that - # changed since $last_update_token but not from the .git folder. - # - # To accomplish this, we're using the "since" generator to use the - # recency index to select candidate nodes and "fields" to limit the - # output to file names only. Then we're using the "expression" term to - # further constrain the results. - my $last_update_line = ""; - if (substr($last_update_token, 0, 1) eq "c") { - $last_update_token = "\"$last_update_token\""; - $last_update_line = qq[\n"since": $last_update_token,]; - } - my $query = <<" END"; - ["query", "$git_work_tree", {$last_update_line - "fields": ["name"], - "expression": ["not", ["dirname", ".git"]] - }] - END - - # Uncomment for debugging the watchman query - # open (my $fh, ">", ".git/watchman-query.json"); - # print $fh $query; - # close $fh; - - print CHLD_IN $query; - close CHLD_IN; - my $response = do {local $/; }; - - # Uncomment for debugging the watch response - # open ($fh, ">", ".git/watchman-response.json"); - # print $fh $response; - # close $fh; - - die "Watchman: command returned no output.\n" . - "Falling back to scanning...\n" if $response eq ""; - die "Watchman: command returned invalid output: $response\n" . - "Falling back to scanning...\n" unless $response =~ /^\{/; - - return $json_pkg->new->utf8->decode($response); -} - -sub is_work_tree_watched { - my ($output) = @_; - my $error = $output->{error}; - if ($retry > 0 and $error and $error =~ m/unable to resolve root .* directory (.*) is not watched/) { - $retry--; - my $response = qx/watchman watch "$git_work_tree"/; - die "Failed to make watchman watch '$git_work_tree'.\n" . - "Falling back to scanning...\n" if $? != 0; - $output = $json_pkg->new->utf8->decode($response); - $error = $output->{error}; - die "Watchman: $error.\n" . - "Falling back to scanning...\n" if $error; - - # Uncomment for debugging watchman output - # open (my $fh, ">", ".git/watchman-output.out"); - # close $fh; - - # Watchman will always return all files on the first query so - # return the fast "everything is dirty" flag to git and do the - # Watchman query just to get it over with now so we won't pay - # the cost in git to look up each individual file. - my $o = watchman_clock(); - $error = $output->{error}; - - die "Watchman: $error.\n" . - "Falling back to scanning...\n" if $error; - - output_result($o->{clock}, ("/")); - $last_update_token = $o->{clock}; - - eval { launch_watchman() }; - return 0; - } - - die "Watchman: $error.\n" . - "Falling back to scanning...\n" if $error; - - return 1; -} - -sub get_working_dir { - my $working_dir; - if ($^O =~ 'msys' || $^O =~ 'cygwin') { - $working_dir = Win32::GetCwd(); - $working_dir =~ tr/\\/\//; - } else { - require Cwd; - $working_dir = Cwd::cwd(); - } - - return $working_dir; -} diff --git a/08-agents/08-09/main (8) b/08-agents/08-09/main (8) deleted file mode 100644 index a1fd29e..0000000 --- a/08-agents/08-09/main (8) +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/sh -# -# An example hook script to make use of push options. -# The example simply echoes all push options that start with 'echoback=' -# and rejects all pushes when the "reject" push option is used. -# -# To enable this hook, rename this file to "pre-receive". - -if test -n "$GIT_PUSH_OPTION_COUNT" -then - i=0 - while test "$i" -lt "$GIT_PUSH_OPTION_COUNT" - do - eval "value=\$GIT_PUSH_OPTION_$i" - case "$value" in - echoback=*) - echo "echo from the pre-receive-hook: ${value#*=}" >&2 - ;; - reject) - exit 1 - esac - i=$((i + 1)) - done -fi diff --git a/08-agents/08-09/main.bicep b/08-agents/08-09/main.bicep deleted file mode 100644 index 4ce688d..0000000 --- a/08-agents/08-09/main.bicep +++ /dev/null @@ -1,53 +0,0 @@ -#!/bin/sh - -# An example hook script to verify what is about to be pushed. Called by "git -# push" after it has checked the remote status, but before anything has been -# pushed. If this script exits with a non-zero status nothing will be pushed. -# -# This hook is called with the following parameters: -# -# $1 -- Name of the remote to which the push is being done -# $2 -- URL to which the push is being done -# -# If pushing without using a named remote those arguments will be equal. -# -# Information about the commits which are being pushed is supplied as lines to -# the standard input in the form: -# -# -# -# This sample shows how to prevent push of commits where the log message starts -# with "WIP" (work in progress). - -remote="$1" -url="$2" - -zero=$(git hash-object --stdin &2 "Found WIP commit in $local_ref, not pushing" - exit 1 - fi - fi -done - -exit 0 diff --git a/08-agents/08-09/main.parameters.json b/08-agents/08-09/main.parameters.json deleted file mode 100644 index 4142082..0000000 --- a/08-agents/08-09/main.parameters.json +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/sh -# -# An example hook script to verify what is about to be committed -# by applypatch from an e-mail message. -# -# The hook should exit with non-zero status after issuing an -# appropriate message if it wants to stop the commit. -# -# To enable this hook, rename this file to "pre-applypatch". - -. git-sh-setup -precommit="$(git rev-parse --git-path hooks/pre-commit)" -test -x "$precommit" && exec "$precommit" ${1+"$@"} -: diff --git a/08-agents/08-09/pack-7ac677fc7b0621d00ee9b2e0ab1512c230d76589.idx b/08-agents/08-09/pack-7ac677fc7b0621d00ee9b2e0ab1512c230d76589.idx deleted file mode 100644 index 29ed5ee..0000000 --- a/08-agents/08-09/pack-7ac677fc7b0621d00ee9b2e0ab1512c230d76589.idx +++ /dev/null @@ -1,49 +0,0 @@ -#!/bin/sh -# -# An example hook script to verify what is about to be committed. -# Called by "git commit" with no arguments. The hook should -# exit with non-zero status after issuing an appropriate message if -# it wants to stop the commit. -# -# To enable this hook, rename this file to "pre-commit". - -if git rev-parse --verify HEAD >/dev/null 2>&1 -then - against=HEAD -else - # Initial commit: diff against an empty tree object - against=$(git hash-object -t tree /dev/null) -fi - -# If you want to allow non-ASCII filenames set this variable to true. -allownonascii=$(git config --type=bool hooks.allownonascii) - -# Redirect output to stderr. -exec 1>&2 - -# Cross platform projects tend to avoid non-ASCII filenames; prevent -# them from being added to the repository. We exploit the fact that the -# printable range starts at the space character and ends with tilde. -if [ "$allownonascii" != "true" ] && - # Note that the use of brackets around a tr range is ok here, (it's - # even required, for portability to Solaris 10's /usr/bin/tr), since - # the square bracket bytes happen to fall in the designated range. - test $(git diff-index --cached --name-only --diff-filter=A -z $against | - LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0 -then - cat <<\EOF -Error: Attempt to add a non-ASCII file name. - -This can cause problems if you want to work with people on other platforms. - -To be portable it is advisable to rename the file. - -If you know what you are doing you can disable this check using: - - git config hooks.allownonascii true -EOF - exit 1 -fi - -# If there are whitespace errors, print the offending file names and fail. -exec git diff-index --check --cached $against -- diff --git a/08-agents/08-09/pack-7ac677fc7b0621d00ee9b2e0ab1512c230d76589.pack b/08-agents/08-09/pack-7ac677fc7b0621d00ee9b2e0ab1512c230d76589.pack deleted file mode 100644 index 640bcf8..0000000 --- a/08-agents/08-09/pack-7ac677fc7b0621d00ee9b2e0ab1512c230d76589.pack +++ /dev/null @@ -1,77 +0,0 @@ -#!/bin/sh - -# An example hook script to validate a patch (and/or patch series) before -# sending it via email. -# -# The hook should exit with non-zero status after issuing an appropriate -# message if it wants to prevent the email(s) from being sent. -# -# To enable this hook, rename this file to "sendemail-validate". -# -# By default, it will only check that the patch(es) can be applied on top of -# the default upstream branch without conflicts in a secondary worktree. After -# validation (successful or not) of the last patch of a series, the worktree -# will be deleted. -# -# The following config variables can be set to change the default remote and -# remote ref that are used to apply the patches against: -# -# sendemail.validateRemote (default: origin) -# sendemail.validateRemoteRef (default: HEAD) -# -# Replace the TODO placeholders with appropriate checks according to your -# needs. - -validate_cover_letter () { - file="$1" - # TODO: Replace with appropriate checks (e.g. spell checking). - true -} - -validate_patch () { - file="$1" - # Ensure that the patch applies without conflicts. - git am -3 "$file" || return - # TODO: Replace with appropriate checks for this patch - # (e.g. checkpatch.pl). - true -} - -validate_series () { - # TODO: Replace with appropriate checks for the whole series - # (e.g. quick build, coding style checks, etc.). - true -} - -# main ------------------------------------------------------------------------- - -if test "$GIT_SENDEMAIL_FILE_COUNTER" = 1 -then - remote=$(git config --default origin --get sendemail.validateRemote) && - ref=$(git config --default HEAD --get sendemail.validateRemoteRef) && - worktree=$(mktemp --tmpdir -d sendemail-validate.XXXXXXX) && - git worktree add -fd --checkout "$worktree" "refs/remotes/$remote/$ref" && - git config --replace-all sendemail.validateWorktree "$worktree" -else - worktree=$(git config --get sendemail.validateWorktree) -fi || { - echo "sendemail-validate: error: failed to prepare worktree" >&2 - exit 1 -} - -unset GIT_DIR GIT_WORK_TREE -cd "$worktree" && - -if grep -q "^diff --git " "$1" -then - validate_patch "$1" -else - validate_cover_letter "$1" -fi && - -if test "$GIT_SENDEMAIL_FILE_COUNTER" = "$GIT_SENDEMAIL_FILE_TOTAL" -then - git config --unset-all sendemail.validateWorktree && - trap 'git worktree remove -ff "$worktree"' EXIT && - validate_series -fi diff --git a/08-agents/08-09/pack-7ac677fc7b0621d00ee9b2e0ab1512c230d76589.rev b/08-agents/08-09/pack-7ac677fc7b0621d00ee9b2e0ab1512c230d76589.rev deleted file mode 100644 index a5d7b84..0000000 --- a/08-agents/08-09/pack-7ac677fc7b0621d00ee9b2e0ab1512c230d76589.rev +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/sh -# -# An example hook script to check the commit log message taken by -# applypatch from an e-mail message. -# -# The hook should exit with non-zero status after issuing an -# appropriate message if it wants to stop the commit. The hook is -# allowed to edit the commit message file. -# -# To enable this hook, rename this file to "applypatch-msg". - -. git-sh-setup -commitmsg="$(git rev-parse --git-path hooks/commit-msg)" -test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"} -: diff --git a/08-agents/08-09/packed-refs b/08-agents/08-09/packed-refs deleted file mode 100644 index fe526a1..0000000 --- a/08-agents/08-09/packed-refs +++ /dev/null @@ -1,14 +0,0 @@ -[core] - repositoryformatversion = 0 - filemode = true - bare = false - logallrefupdates = true - ignorecase = true - precomposeunicode = true -[remote "origin"] - url = https://github.com/ArlindNocaj/foundry-copilot-sdk-hosted.git - fetch = +refs/heads/*:refs/remotes/origin/* -[branch "main"] - remote = origin - merge = refs/heads/main - vscode-merge-base = origin/main diff --git a/08-agents/08-09/pre-applypatch.sample b/08-agents/08-09/pre-applypatch.sample deleted file mode 100644 index 1836fa7..0000000 --- a/08-agents/08-09/pre-applypatch.sample +++ /dev/null @@ -1 +0,0 @@ -0000000000000000000000000000000000000000 114f0012b64de74ca55c772f4a4b91cf345d30a3 ArlindNocaj <2978097+ArlindNocaj@users.noreply.github.com> 1778824870 +0200 clone: from https://github.com/ArlindNocaj/foundry-copilot-sdk-hosted.git diff --git a/08-agents/08-09/pre-merge-commit.sample b/08-agents/08-09/pre-merge-commit.sample deleted file mode 100644 index a5196d1..0000000 --- a/08-agents/08-09/pre-merge-commit.sample +++ /dev/null @@ -1,6 +0,0 @@ -# git ls-files --others --exclude-from=.git/info/exclude -# Lines that start with '#' are comments. -# For a project mostly in C, the following would be a good set of -# exclude patterns (uncomment them if you want to use them): -# *.[oa] -# *~ diff --git a/08-agents/08-09/pre-rebase.sample b/08-agents/08-09/pre-rebase.sample deleted file mode 100644 index 498b267..0000000 --- a/08-agents/08-09/pre-rebase.sample +++ /dev/null @@ -1 +0,0 @@ -Unnamed repository; edit this file 'description' to name the repository. diff --git a/08-agents/08-09/pre-receive.sample b/08-agents/08-09/pre-receive.sample deleted file mode 100644 index 8290aea..0000000 --- a/08-agents/08-09/pre-receive.sample +++ /dev/null @@ -1 +0,0 @@ -114f0012b64de74ca55c772f4a4b91cf345d30a3 branch 'main' of https://github.com/ArlindNocaj/foundry-copilot-sdk-hosted diff --git a/08-agents/08-09/push-to-checkout.sample b/08-agents/08-09/push-to-checkout.sample deleted file mode 100644 index 6cbef5c..0000000 --- a/08-agents/08-09/push-to-checkout.sample +++ /dev/null @@ -1,169 +0,0 @@ -#!/bin/sh -# -# Copyright (c) 2006, 2008 Junio C Hamano -# -# The "pre-rebase" hook is run just before "git rebase" starts doing -# its job, and can prevent the command from running by exiting with -# non-zero status. -# -# The hook is called with the following parameters: -# -# $1 -- the upstream the series was forked from. -# $2 -- the branch being rebased (or empty when rebasing the current branch). -# -# This sample shows how to prevent topic branches that are already -# merged to 'next' branch from getting rebased, because allowing it -# would result in rebasing already published history. - -publish=next -basebranch="$1" -if test "$#" = 2 -then - topic="refs/heads/$2" -else - topic=`git symbolic-ref HEAD` || - exit 0 ;# we do not interrupt rebasing detached HEAD -fi - -case "$topic" in -refs/heads/??/*) - ;; -*) - exit 0 ;# we do not interrupt others. - ;; -esac - -# Now we are dealing with a topic branch being rebased -# on top of master. Is it OK to rebase it? - -# Does the topic really exist? -git show-ref -q "$topic" || { - echo >&2 "No such branch $topic" - exit 1 -} - -# Is topic fully merged to master? -not_in_master=`git rev-list --pretty=oneline ^master "$topic"` -if test -z "$not_in_master" -then - echo >&2 "$topic is fully merged to master; better remove it." - exit 1 ;# we could allow it, but there is no point. -fi - -# Is topic ever merged to next? If so you should not be rebasing it. -only_next_1=`git rev-list ^master "^$topic" ${publish} | sort` -only_next_2=`git rev-list ^master ${publish} | sort` -if test "$only_next_1" = "$only_next_2" -then - not_in_topic=`git rev-list "^$topic" master` - if test -z "$not_in_topic" - then - echo >&2 "$topic is already up to date with master" - exit 1 ;# we could allow it, but there is no point. - else - exit 0 - fi -else - not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"` - /usr/bin/perl -e ' - my $topic = $ARGV[0]; - my $msg = "* $topic has commits already merged to public branch:\n"; - my (%not_in_next) = map { - /^([0-9a-f]+) /; - ($1 => 1); - } split(/\n/, $ARGV[1]); - for my $elem (map { - /^([0-9a-f]+) (.*)$/; - [$1 => $2]; - } split(/\n/, $ARGV[2])) { - if (!exists $not_in_next{$elem->[0]}) { - if ($msg) { - print STDERR $msg; - undef $msg; - } - print STDERR " $elem->[1]\n"; - } - } - ' "$topic" "$not_in_next" "$not_in_master" - exit 1 -fi - -<<\DOC_END - -This sample hook safeguards topic branches that have been -published from being rewound. - -The workflow assumed here is: - - * Once a topic branch forks from "master", "master" is never - merged into it again (either directly or indirectly). - - * Once a topic branch is fully cooked and merged into "master", - it is deleted. If you need to build on top of it to correct - earlier mistakes, a new topic branch is created by forking at - the tip of the "master". This is not strictly necessary, but - it makes it easier to keep your history simple. - - * Whenever you need to test or publish your changes to topic - branches, merge them into "next" branch. - -The script, being an example, hardcodes the publish branch name -to be "next", but it is trivial to make it configurable via -$GIT_DIR/config mechanism. - -With this workflow, you would want to know: - -(1) ... if a topic branch has ever been merged to "next". Young - topic branches can have stupid mistakes you would rather - clean up before publishing, and things that have not been - merged into other branches can be easily rebased without - affecting other people. But once it is published, you would - not want to rewind it. - -(2) ... if a topic branch has been fully merged to "master". - Then you can delete it. More importantly, you should not - build on top of it -- other people may already want to - change things related to the topic as patches against your - "master", so if you need further changes, it is better to - fork the topic (perhaps with the same name) afresh from the - tip of "master". - -Let's look at this example: - - o---o---o---o---o---o---o---o---o---o "next" - / / / / - / a---a---b A / / - / / / / - / / c---c---c---c B / - / / / \ / - / / / b---b C \ / - / / / / \ / - ---o---o---o---o---o---o---o---o---o---o---o "master" - - -A, B and C are topic branches. - - * A has one fix since it was merged up to "next". - - * B has finished. It has been fully merged up to "master" and "next", - and is ready to be deleted. - - * C has not merged to "next" at all. - -We would want to allow C to be rebased, refuse A, and encourage -B to be deleted. - -To compute (1): - - git rev-list ^master ^topic next - git rev-list ^master next - - if these match, topic has not merged in next at all. - -To compute (2): - - git rev-list master..topic - - if this is empty, it is fully merged to "master". - -DOC_END diff --git a/08-agents/08-09/tmp.txt b/08-agents/08-09/tmp.txt deleted file mode 100644 index 8b13789..0000000 --- a/08-agents/08-09/tmp.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/08-agents/08-09/tracing.py b/08-agents/08-09/tracing.py deleted file mode 100644 index 1836fa7..0000000 --- a/08-agents/08-09/tracing.py +++ /dev/null @@ -1 +0,0 @@ -0000000000000000000000000000000000000000 114f0012b64de74ca55c772f4a4b91cf345d30a3 ArlindNocaj <2978097+ArlindNocaj@users.noreply.github.com> 1778824870 +0200 clone: from https://github.com/ArlindNocaj/foundry-copilot-sdk-hosted.git diff --git a/08-agents/08-09/update.sample b/08-agents/08-09/update.sample deleted file mode 100644 index b58d118..0000000 --- a/08-agents/08-09/update.sample +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/sh -# -# An example hook script to check the commit log message. -# Called by "git commit" with one argument, the name of the file -# that has the commit message. The hook should exit with non-zero -# status after issuing an appropriate message if it wants to stop the -# commit. The hook is allowed to edit the commit message file. -# -# To enable this hook, rename this file to "commit-msg". - -# Uncomment the below to add a Signed-off-by line to the message. -# Doing this in a hook is a bad idea in general, but the prepare-commit-msg -# hook is more suited to it. -# -# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p') -# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1" - -# This example catches duplicate Signed-off-by lines. - -test "" = "$(grep '^Signed-off-by: ' "$1" | - sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || { - echo >&2 Duplicate Signed-off-by lines. - exit 1 -} diff --git a/08-agents/08-10-hosted-copilot-sdk-agent/.gitignore b/08-agents/08-10-hosted-copilot-sdk-agent/.gitignore new file mode 100644 index 0000000..4b72a32 --- /dev/null +++ b/08-agents/08-10-hosted-copilot-sdk-agent/.gitignore @@ -0,0 +1,13 @@ +.azure +.env +*.env +**/.env +**/.venv/ +**/__pycache__/ +*.log + +# Notebook-generated bicep params (substituted from ${AZURE_*} placeholders at runtime) +infra/.main.parameters.runtime.json + +# Agent-generated chart artifacts from Step 7.8 (regenerated on each run) +data/license_cost_by_department.* diff --git a/08-agents/08-10-hosted-copilot-sdk-agent/08-10-00-hosted-copilot-sdk-agent.md b/08-agents/08-10-hosted-copilot-sdk-agent/08-10-00-hosted-copilot-sdk-agent.md new file mode 100644 index 0000000..5c317ea --- /dev/null +++ b/08-agents/08-10-hosted-copilot-sdk-agent/08-10-00-hosted-copilot-sdk-agent.md @@ -0,0 +1,160 @@ +# Foundry-hosted agent powered by the GitHub Copilot SDK + +This lab deploys a containerised agent built on the [**GitHub Copilot SDK**](https://pypi.org/project/github-copilot-sdk/) and hosts it as a **Microsoft Foundry hosted agent**. Inference is served by a **Foundry-deployed `gpt-5.4-mini` model** wired in via BYOK (Bring Your Own Key; here, your own Foundry model reached through Managed Identity), so the container needs no secrets at runtime. + +The notebook follows the same pattern as the rest of this repo (see [`08-03-hosted-agents`](../08-03-hosted-agents/08-03-00-hosted-agents.md)): `az deployment sub create` runs the Bicep, `az acr build` builds the container, and `AIProjectClient.agents.create_version` registers it with the project as a hosted agent. + +## What this example demonstrates + +- **Hosted agent runtime.** Foundry runs the container for you under the project's capability host. There is no Container App, Web App, or AKS to manage. +- **Copilot SDK as the agent loop.** `CopilotClient` owns sessions, tool-calling, streaming, and skill discovery. Your `main.py` only forwards SSE events out through Foundry's [invocations protocol](https://pypi.org/project/azure-ai-agentserver-invocations/). +- **BYOK Foundry model via Managed Identity.** When `AZURE_AI_PROJECT_ENDPOINT` + `AZURE_AI_MODEL_DEPLOYMENT_NAME` are set, the Copilot SDK routes every chat turn to your Foundry model over the project's OpenAI surface (`/openai/v1/`, token audience `ai.azure.com/.default`) - no GitHub PAT, no OpenAI key. Set only `GITHUB_TOKEN` and it falls back to the GitHub Copilot model; if both are set, the Foundry model wins. +- **Two extension surfaces:** + - `system_prompt.md` for persona / global policy that applies to every turn. + - `skills//SKILL.md` for task-specific procedures the model discovers on demand (an `m365-license-analytics` skill is included - it carries the analysis method, while the cost/department data is uploaded separately). +- **OTel tracing into Foundry portal.** `tracing.py` maps `SessionEvent`s to GenAI semantic-convention spans, so the Foundry Tracing tab shows the per-invocation tree with token usage and estimated cost. + +## How the agentic loop works + +The agent runs **two nested loops**. The reason/act/observe loop that makes it agentic does **not** live in `main.py` - it runs inside a **Copilot CLI subprocess** that the SDK spawns. `main.py` is a thin Foundry-protocol shell that boots that subprocess and forwards its event stream. + +``` +Foundry hosted runtime + └── container: `python main.py` (Dockerfile CMD, port 8088) + └── InvocationAgentServerHost outer loop: one HTTP POST /invocations per user turn + └── CopilotClient -> spawns the Copilot CLI subprocess + └── inner agentic loop: model <-> tools, until the session goes idle +``` + +### Inner loop - the agentic part, inside the Copilot CLI + +`_ensure_session` calls `_client.start()`, which boots the Copilot CLI subprocess. The CLI is the agent harness: it owns the system prompt, **skill discovery** (`skill_directories`), and **tool execution** (shell, python, file edits). Each `session.send(input)` runs one turn of the loop inside the CLI: + +``` +send(user_text) + -> model call (chat) emits assistant.message_delta (streamed text) + ASSISTANT_USAGE (tokens) + -> model wants a tool? emits TOOL_EXECUTION_START + run shell/python/... + emits TOOL_EXECUTION_COMPLETE + -> feed tool result back, call the model again (repeat: reason -> act -> observe) + -> no more tool calls, final answer +SESSION_IDLE the turn is complete +``` + +`main.py` never decides tool use or sees individual model calls - it only observes these events. `tracing.py` maps them to the span tree shown below (`TOOL_EXECUTION_*` -> `execute_tool`, `ASSISTANT_USAGE` -> `chat `), so a single CSV-analytics turn shows multiple `execute_tool` spans: that span tree *is* the agentic loop made visible. The inner loop's model calls are routed to your Foundry model by `_byok_provider` (see [How the BYOK wiring works](#how-the-byok-wiring-works) below). + +### Outer loop - the request and event drain, in `main.py` + +Foundry calls `POST /invocations` once per user message. `handle_invoke` validates `{"input": "..."}` and returns a streaming response backed by `_stream_response`, which bridges the SDK's callback world to SSE with an `asyncio.Queue`: + +- `session.on(on_event)` pushes every event onto the queue; `SESSION_IDLE` pushes a `None` sentinel and `SESSION_ERROR` pushes an exception. +- a `while True` drain loop awaits the queue and `yield`s each event as an SSE `data:` frame until the `None` sentinel arrives, then emits `event: done`. + +This `while` loop is an **event-drain** loop, not the agentic loop - the reasoning has already happened inside the CLI subprocess. + +### Multi-turn conversation + +Two mechanisms stack, so a follow-up request starts with the full prior context already loaded: + +1. **In-process singleton.** `_session` is a module global; the first request lazy-creates it and every later request reuses the same Copilot session, so the CLI subprocess keeps the message thread in memory. Turn 2 already remembers turn 1. +2. **Resume by ID for durability.** The session is keyed to `FOUNDRY_AGENT_SESSION_ID` (injected by the runtime; falls back to a generated UUID). On startup the agent tries `resume_session(session_id)` first and only falls back to `create_session`, so a restarted container resumes the same conversation by ID rather than starting cold. + +> **Design assumption:** `_session` is a single global and `_session_id` is read once from the environment, so the agent assumes **one logical conversation per container instance** (the hosted runtime stamps the session id into the container's env). To fan one container across multiple concurrent users, replace the singleton with a `dict[session_id -> session]` keyed cache. + +## How the BYOK wiring works + +``` ++---------------------------------+ +--------------------------+ +| Hosted agent container | | Foundry AI Services | +| | | account | +| CopilotClient(provider=openai) | | | +| | bearer = MI token | HTTPS | services.ai.azure.com/ | +| v |------->| …/openai/v1/responses | +| azure-ai-agentserver- | | | +| invocations | | gpt-5.4-mini deployment | ++------------+--------------------+ +--------------------------+ + | SSE + v + requests.post(...&agent_session_id=...) + (helpers in the notebook) +``` + +Container env vars (set at registration time via `AIProjectClient.agents.create_version(..., environment_variables=...)` in the notebook): + +| Variable | Source | Why | +|---|---|---| +| `AZURE_AI_PROJECT_ENDPOINT` | Not hand-configured - it is a Bicep output, read back in **Step 2** as the `PROJECT_ENDPOINT` variable and injected here at registration (the platform also auto-injects the same value as `FOUNDRY_PROJECT_ENDPOINT`) | `main.py` appends `/openai/v1/` and uses `/openai/v1/` as the Copilot SDK provider `base_url`, with token audience `ai.azure.com` | +| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Hardcoded in the `DEPLOYMENT_NAME` variable in **Step 1** (defaults to `gpt-5.4-mini`). To use a different model, change it there - the same variable also names the Bicep model deployment created in Step 2, so provisioning and BYOK routing stay in sync | Model deployment to route inference to | +| `AZURE_CLIENT_ID` | `instance_identity.client_id` from agent version metadata | Disambiguates the **AgentIdentity** managed identity inside `DefaultAzureCredential`. Each hosted-agent version has two identities in metadata: `AgentIdentityBlueprint` (a template, NOT used at runtime) and `instance_identity` = `AgentIdentity` (the actual Entra SP the container assumes). RBAC grants and `AZURE_CLIENT_ID` both target AgentIdentity | +| `APPLICATIONINSIGHTS_CONNECTION_STRING` | auto-injected by platform | OTel export target | + +RBAC role grants on the AgentIdentity principal (Step 5 of the notebook): +- `AcrPull` on the ACR (image pull) +- `Foundry User` on the project (general data plane) +- `Cognitive Services OpenAI User` on the **account** scope (specifically grants `/openai/v1/responses/*` data actions) +- `Cognitive Services User` on the account (broader safety net) + +## What the agent is allowed to do (permission model) + +The Copilot CLI gates each tool call (shell, python, file read/write/edit) behind a permission request. In an interactive CLI a human approves each one; a hosted agent has nobody at a terminal, so `main.py` answers those requests programmatically through a single callback: + +```python +on_permission_request=PermissionHandler.approve_all, # main.py +``` + +`approve_all` auto-approves every request - the direct equivalent of Copilot CLI yolo mode or Claude's `--dangerously-skip-permissions`. There is **no `settings.local.json`-style file** in this lab: in the embedded-SDK hosting model the host app owns the gate, and that one callback is the single in-process control point. + +To gate tool use, replace `approve_all` with your own callback that inspects each request (tool name plus arguments, for example the shell command or target path) and returns an approve or deny decision - that is where an allow-list / deny-list belongs. + +In practice "what the agent can do" is governed by four layers, weakest to strongest: + +| Layer | Where | Strength | +|---|---|---| +| Permission callback | `on_permission_request` in `main.py` (currently `approve_all`) | The in-process gate. Open by default; tighten with a custom callback | +| Working directory | `working_directory=$HOME` in `main.py` | Scopes filesystem ops to the session sandbox | +| System prompt policy | `system_prompt.md` | Soft - guidance the model may or may not follow | +| Container identity + RBAC | per-agent **AgentIdentity** + the Step 5 role grants + platform network egress | Hard boundary - even with `approve_all` the agent acts only as the managed identity and cannot exceed its Azure permissions or reach resources it has no role on | + +The identity layer is the one that actually contains the agent: `approve_all` lets it run any tool, but it still acts only as the AgentIdentity with the four roles from Step 5, so the blast radius is whatever that principal can touch. Grant narrowly. + +Every tool call is also fully **observable**: it emits `TOOL_EXECUTION_START` / `TOOL_EXECUTION_COMPLETE` events (streamed as SSE) and an `execute_tool ` span in Foundry Tracing, so you get an audit trail of every shell / python / file action even though nothing is gated. + +## When this pattern is interesting + +| Use case | Why this stack fits | +|---|---| +| Internal devops / coding assistants | Copilot SDK already understands shell, code, file edits, and skill discovery. You inherit that. | +| Domain agents that need persona + procedures | `system_prompt.md` is persona; `skills/*` are procedures. Two clean knobs. | +| Compliance / sovereignty constraints on model traffic | Inference stays inside your Foundry project (BYOK Foundry model + Managed Identity), not GitHub Copilot's backend. | +| Multi-turn sessions with resume | Copilot SDK caches the session by `FOUNDRY_AGENT_SESSION_ID`; the agent resumes across container restarts. | + +## When to choose a different pattern + +| Need | Pattern in this repo | +|---|---| +| Containerised Microsoft Agent Framework agent against an APIM-fronted core gateway | [`08-03-hosted-agents`](../08-03-hosted-agents/08-03-00-hosted-agents.md) | +| MCP tool servers backing a hosted agent | [`08-05-contoso-pmo-mcp`](../08-05-contoso-pmo-mcp/) | +| Vector-store-backed agent with grounding | The Foundry IQ / Bing Grounding stack referenced in [`infra/core/search/`](./infra/core/search/) is wired but disabled by default here | + +## Files + +| File | Purpose | +|---|---| +| [`08-10-01-deploy-hosted-copilot-sdk-agent.ipynb`](08-10-01-deploy-hosted-copilot-sdk-agent.ipynb) | Walks the full `az deployment sub create` → `az acr build` → `AIProjectClient.agents.create_version` → role-grant → invoke loop, then uploads `data/m365-licenses.csv` into a session, runs five M365 license analytics prompts through the deployed agent, and has the agent render a cost-by-department chart that the notebook downloads back out of the sandbox | +| [`data/m365-licenses.csv`](data/m365-licenses.csv) | Synthetic M365 license export (100 users, real SKUs, engineered outliers: 5 disabled-but-licensed, 6 stale SPE_E5 holders, 14 ghost users). Used by Step 7 of the notebook to demonstrate hosted-agent session file ops | +| [`data/m365-reference.json`](data/m365-reference.json) | Per-SKU monthly costs + department-code names. Uploaded to the session in Step 7 so the agent joins costs/names onto the licenses CSV - keeps this volatile data out of the skill | +| [`infra/main.bicep`](infra/main.bicep) | Subscription-scoped Bicep that provisions the AI Foundry account, project, model deployment, ACR, capability host, and (optionally) App Insights / Log Analytics / Bing Grounding / AI Search | +| [`src/github-copilot-invocations/main.py`](src/github-copilot-invocations/main.py) | The agent. Picks `GITHUB_TOKEN` vs Foundry BYOK at startup, manages a singleton Copilot session, streams `SessionEvent`s as SSE | +| [`src/github-copilot-invocations/system_prompt.md`](src/github-copilot-invocations/system_prompt.md) | Persona appended to the Copilot CLI's built-in system message | +| [`src/github-copilot-invocations/skills/m365-license-analytics/SKILL.md`](src/github-copilot-invocations/skills/m365-license-analytics/SKILL.md) | Skill supplying the M365 license analysis method, column glossary, and reclaim definition - costs and department names come from the uploaded reference, not the skill | +| [`src/github-copilot-invocations/tracing.py`](src/github-copilot-invocations/tracing.py) | OTel span tree (`invoke_agent` → `execute_tool` / `chat `) emitted to Application Insights | + +## Prerequisites + +- Azure subscription with **Cognitive Services** quota for `gpt-5.4-mini` in `swedencentral` (or another supported region listed in `infra/main.bicep`). +- `az` CLI (signed in via `az login`) and `python>=3.11`. +- Python packages: `azure-ai-projects>=2.1.0`, `azure-identity`, `requests` (provided by the repo's `uv` environment). + +--- + +[Next: Deploy the hosted Copilot SDK agent →](08-10-01-deploy-hosted-copilot-sdk-agent.ipynb) diff --git a/08-agents/08-10-hosted-copilot-sdk-agent/08-10-01-deploy-hosted-copilot-sdk-agent.ipynb b/08-agents/08-10-hosted-copilot-sdk-agent/08-10-01-deploy-hosted-copilot-sdk-agent.ipynb new file mode 100644 index 0000000..d8b1fdf --- /dev/null +++ b/08-agents/08-10-hosted-copilot-sdk-agent/08-10-01-deploy-hosted-copilot-sdk-agent.ipynb @@ -0,0 +1,1110 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "63297545", + "metadata": {}, + "source": [ + "# Deploy the hosted Copilot SDK agent\n", + "\n", + "This notebook stands up the `github-copilot` agent end to end using **Azure CLI + the `azure-ai-projects` Python SDK**, the same pattern as every other example in this repo (see [`08-03-hosted-agents`](../08-03-hosted-agents/08-03-01-deploy-hosted-agent.ipynb)).\n", + "\n", + "Every action is explicit and visible - the notebook drives the deployment directly with the Azure CLI and SDK.\n", + "\n", + "By the end you will have:\n", + "\n", + "- A new AI Foundry account + project provisioned in `swedencentral` from `infra/main.bicep` (via `az deployment sub create`).\n", + "- A `gpt-5.4-mini` GlobalStandard model deployment in the project.\n", + "- The Copilot SDK agent container built and pushed via `az acr build`, then registered as a hosted agent (`AgentProtocol.INVOCATIONS`) via `AIProjectClient.agents.create_version`.\n", + "- The per-agent managed identity granted `AcrPull` on the ACR plus the data-plane roles it needs to pull the image and call the model.\n", + "- Two smoke-test invocations against the deployed agent over `POST .../endpoint/protocols/invocations`.\n", + "- A CSV analytics demo: upload `data/m365-licenses.csv` plus a `data/m365-reference.json` (SKU costs + department names) to a session sandbox, then run five M365 license analytics prompts through the agent using the [session file-ops REST API](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/manage-hosted-sessions?pivots=rest#session-file-operations).\n", + "- OpenTelemetry traces visible in **Foundry portal -> Tracing**.\n", + "\n", + "This folder bundles the subscription-scoped Bicep (`infra/`), the agent container (`src/github-copilot-invocations/`), and a synthetic dataset (`data/m365-licenses.csv`). See [`08-10-00-hosted-copilot-sdk-agent.md`](08-10-00-hosted-copilot-sdk-agent.md) for what each file in this folder does." + ] + }, + { + "cell_type": "markdown", + "id": "9f6d9b98", + "metadata": {}, + "source": [ + "## Prerequisites\n", + "\n", + "1. **`az` CLI** logged in: `az login` (the prereq cell asserts this).\n", + "2. **Python packages** (provided by the repo's `uv` environment): `azure-ai-projects>=2.1.0`, `azure-identity`, `requests`.\n", + "3. **Quota** for `gpt-5.4-mini` GlobalStandard in `swedencentral` (or change `LOCATION` below and the bicep `@allowed` list).\n", + "\n", + "Run this notebook from the folder it lives in (`08-agents/08-10-hosted-copilot-sdk-agent/`) so the relative paths to `infra/`, `src/`, and `data/` resolve." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5a9c65b9", + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "import pathlib\n", + "import shutil\n", + "import subprocess\n", + "\n", + "def sh(cmd, **kw):\n", + " \"\"\"Run a shell command, stream output, return CompletedProcess.\"\"\"\n", + " print(f\"$ {cmd}\")\n", + " return subprocess.run(cmd, shell=True, check=False, **kw)\n", + "\n", + "def _require(binary, install_hint):\n", + " if shutil.which(binary) is None:\n", + " raise AssertionError(\n", + " f\"`{binary}` is not on PATH. Install it and restart the kernel:\\n {install_hint}\"\n", + " )\n", + "\n", + "_require(\"az\", \"Ubuntu: https://learn.microsoft.com/cli/azure/install-azure-cli-linux\")\n", + "\n", + "sh(\"az --version | head -1\")\n", + "\n", + "# Require az login\n", + "who = subprocess.run(\n", + " \"az account show --query '{name:name, id:id, tenant:tenantId, user:user.name}' -o jsonc\",\n", + " shell=True, capture_output=True, text=True,\n", + ")\n", + "if who.returncode != 0:\n", + " raise AssertionError(\n", + " \"Not logged in to Azure CLI. Run `az login` in a terminal, then re-run this cell.\\n\"\n", + " f\"az stderr: {who.stderr.strip()}\"\n", + " )\n", + "print(who.stdout)\n" + ] + }, + { + "cell_type": "markdown", + "id": "5ff509a5", + "metadata": {}, + "source": [ + "## Step 1 - Variables and principal ID\n", + "\n", + "Pick a short, lowercase **environment name** - it stamps the `azd-env-name` tag on every resource and names the resource group (`rg-`). The Foundry **account** and **project** follow this repo's convention - `aif-copilot-sdk-` and `project-copilot-sdk-` - where the 6-char suffix (a hash of the subscription + environment name) keeps the globally-unique account FQDN distinct across developers.\n", + "\n", + "The current caller's principal ID (extracted from the JWT, not via a Graph call) is passed to bicep as `principalId` so role assignments target the right identity." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a09a5826", + "metadata": {}, + "outputs": [], + "source": [ + "import base64\n", + "import hashlib\n", + "\n", + "ENV_NAME = \"foundry-copilot-sdk-08-10\"\n", + "LOCATION = \"swedencentral\"\n", + "DEPLOYMENT_NAME = \"gpt-5.4-mini\"\n", + "AGENT_NAME = \"github-copilot\"\n", + "RESOURCE_GROUP = f\"rg-{ENV_NAME}\"\n", + "\n", + "# Subscription ID - used to build resource IDs for the role grants in Step 5\n", + "SUBSCRIPTION_ID = subprocess.run(\n", + " \"az account show --query id -o tsv\",\n", + " shell=True, capture_output=True, text=True,\n", + ").stdout.strip()\n", + "\n", + "# 6-char suffix for globally-unique resource names, following this repo's\n", + "# convention (e.g. aif-spoke-alpha-c2676f) - the account FQDN must be globally unique.\n", + "SUFFIX = hashlib.sha256((SUBSCRIPTION_ID + ENV_NAME).encode()).hexdigest()[:6]\n", + "AI_ACCOUNT_NAME = f\"aif-copilot-sdk-{SUFFIX}\"\n", + "AI_PROJECT_NAME = f\"project-copilot-sdk-{SUFFIX}\"\n", + "\n", + "# Principal ID from JWT - avoids a Graph round-trip\n", + "token = subprocess.run(\n", + " \"az account get-access-token --query accessToken -o tsv\",\n", + " shell=True, capture_output=True, text=True,\n", + ").stdout.strip()\n", + "PRINCIPAL_ID = json.loads(base64.urlsafe_b64decode(token.split('.')[1] + '=='))['oid']\n", + "PRINCIPAL_TYPE = \"User\"\n", + "\n", + "print(f\"Subscription: {SUBSCRIPTION_ID}\")\n", + "print(f\"Resource group: {RESOURCE_GROUP}\")\n", + "print(f\"Location: {LOCATION}\")\n", + "print(f\"Env name: {ENV_NAME}\")\n", + "print(f\"AI account: {AI_ACCOUNT_NAME}\")\n", + "print(f\"AI project: {AI_PROJECT_NAME}\")\n", + "print(f\"Agent name: {AGENT_NAME}\")\n", + "print(f\"Model: {DEPLOYMENT_NAME}\")\n", + "print(f\"Principal ID: {PRINCIPAL_ID}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "8a46fcc3", + "metadata": {}, + "source": [ + "## Step 2 - Provision the Foundry stack with Bicep\n", + "\n", + "`infra/main.bicep` is subscription-scoped, so we use `az deployment sub create`. The bicep:\n", + "\n", + "1. Creates resource group `rg-`.\n", + "2. Provisions an AI Services account + a Foundry project.\n", + "3. Deploys the model named in `DEPLOYMENT_NAME` (GlobalStandard, capacity 100).\n", + "4. Creates an Azure Container Registry connected to the project.\n", + "5. Creates Application Insights + Log Analytics for tracing.\n", + "6. Provisions the **capability host** (the hosted-agent runtime) because `enableCapabilityHost=true` is passed.\n", + "\n", + "We assemble the deployment parameters in code from the values above and write them to a file next to the template, then pass it to `az deployment sub create`.\n", + "\n", + "Provisioning typically takes **5-10 minutes**. Watch the stream below; on success, the cell after this one extracts the bicep outputs we need for the next steps.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "373d5007", + "metadata": {}, + "outputs": [], + "source": [ + "# Build the bicep model-deployment parameter we pass to the template.\n", + "ai_project_deployments = [\n", + " {\n", + " \"name\": DEPLOYMENT_NAME,\n", + " \"model\": {\n", + " \"format\": \"OpenAI\",\n", + " \"name\": DEPLOYMENT_NAME,\n", + " \"version\": \"2026-03-17\",\n", + " },\n", + " \"sku\": {\n", + " \"name\": \"GlobalStandard\",\n", + " \"capacity\": 100,\n", + " },\n", + " }\n", + "]\n", + "\n", + "bicep_params = {\n", + " \"$schema\": \"https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#\",\n", + " \"contentVersion\": \"1.0.0.0\",\n", + " \"parameters\": {\n", + " \"environmentName\": {\"value\": ENV_NAME},\n", + " \"location\": {\"value\": LOCATION},\n", + " \"aiDeploymentsLocation\": {\"value\": LOCATION},\n", + " \"aiFoundryResourceName\": {\"value\": AI_ACCOUNT_NAME},\n", + " \"aiFoundryProjectName\": {\"value\": AI_PROJECT_NAME},\n", + " \"principalId\": {\"value\": PRINCIPAL_ID},\n", + " \"principalType\": {\"value\": PRINCIPAL_TYPE},\n", + " \"aiProjectDeploymentsJson\": {\"value\": json.dumps(ai_project_deployments)},\n", + " \"enableHostedAgents\": {\"value\": True},\n", + " \"enableCapabilityHost\": {\"value\": True},\n", + " \"enableMonitoring\": {\"value\": True},\n", + " },\n", + "}\n", + "\n", + "PARAMS_FILE = pathlib.Path(\"infra\") / \".main.parameters.runtime.json\"\n", + "PARAMS_FILE.write_text(json.dumps(bicep_params, indent=2))\n", + "print(f\"Wrote {PARAMS_FILE}\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5f0d2525", + "metadata": {}, + "outputs": [], + "source": [ + "# Subscription-scope deployment - this is the slow one (5-10 min). Streams output live.\n", + "DEPLOY_NAME = f\"deploy-{ENV_NAME}\"\n", + "sh(\n", + " f'az deployment sub create '\n", + " f'--name \"{DEPLOY_NAME}\" '\n", + " f'--location \"{LOCATION}\" '\n", + " f'--template-file infra/main.bicep '\n", + " f'--parameters @\"{PARAMS_FILE}\" '\n", + " f'-o table'\n", + ")\n" + ] + }, + { + "cell_type": "markdown", + "id": "f56d6aec", + "metadata": {}, + "source": [ + "### Extract bicep outputs\n", + "\n", + "We need the project endpoint, ACR endpoint, App Insights connection string, and the account/project names for the rest of the notebook.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a721f755", + "metadata": {}, + "outputs": [], + "source": [ + "r = subprocess.run(\n", + " f'az deployment sub show --name \"{DEPLOY_NAME}\" --query properties.outputs -o json',\n", + " shell=True, capture_output=True, text=True,\n", + ")\n", + "assert r.returncode == 0, r.stderr\n", + "raw_outputs = json.loads(r.stdout)\n", + "outputs = {k.upper(): v[\"value\"] for k, v in raw_outputs.items()}\n", + "\n", + "PROJECT_ENDPOINT = outputs[\"AZURE_AI_PROJECT_ENDPOINT\"]\n", + "ACR_LOGIN_SERVER = outputs[\"AZURE_CONTAINER_REGISTRY_ENDPOINT\"]\n", + "ACR_NAME = ACR_LOGIN_SERVER.split(\".\")[0]\n", + "ACCOUNT_NAME = outputs[\"AZURE_AI_ACCOUNT_NAME\"]\n", + "PROJECT_NAME = outputs[\"AZURE_AI_PROJECT_NAME\"]\n", + "APPINSIGHTS_CONN_STR = outputs.get(\"APPLICATIONINSIGHTS_CONNECTION_STRING\", \"\")\n", + "\n", + "print(f\"Project endpoint: {PROJECT_ENDPOINT}\")\n", + "print(f\"Account name: {ACCOUNT_NAME}\")\n", + "print(f\"Project name: {PROJECT_NAME}\")\n", + "print(f\"ACR login server: {ACR_LOGIN_SERVER}\")\n", + "print(f\"ACR name: {ACR_NAME}\")\n", + "print(f\"App Insights: {APPINSIGHTS_CONN_STR[:60]}{'...' if len(APPINSIGHTS_CONN_STR) > 60 else ''}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "b563e9e8", + "metadata": {}, + "source": [ + "## Step 3 - Build the agent image with `az acr build`\n", + "\n", + "ACR remote build - same pattern as 08-03 (`az acr build --registry $ACR --platform linux/amd64 ./agent-dir/`). The platform flag matters: Foundry's hosted runtime is `linux/amd64`, so Apple Silicon hosts must not produce native ARM images.\n", + "\n", + "A first build takes about **2-4 minutes**.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c581fd09", + "metadata": {}, + "outputs": [], + "source": [ + "print(f\"Building {AGENT_NAME}:latest for linux/amd64 on ACR {ACR_NAME}...\")\n", + "sh(\n", + " f'az acr build --registry \"{ACR_NAME}\" '\n", + " f'--image \"{AGENT_NAME}:latest\" '\n", + " f'--platform linux/amd64 '\n", + " f'./src/github-copilot-invocations/'\n", + ")\n" + ] + }, + { + "cell_type": "markdown", + "id": "adaab6f2", + "metadata": {}, + "source": [ + "## Step 4 - Register the agent as a hosted version\n", + "\n", + "Use `AIProjectClient.agents.create_version` (same SDK call as 08-03 - the only difference is the protocol). 08-03 registers with `AgentProtocol.RESPONSES` because its container speaks Microsoft Agent Framework\\'s `ResponsesHostServer`; this agent registers with `AgentProtocol.INVOCATIONS` because `main.py` uses `azure.ai.agentserver.invocations.InvocationAgentServerHost`.\n", + "\n", + "**Two-pass registration.** Each hosted-agent version metadata exposes two identities: `AgentIdentityBlueprint` (a *template* used by the platform to provision per-version identities) and `AgentIdentity` (`instance_identity` in the API) which is the **actual Entra service principal the container assumes at runtime**. The blueprint exists at registration time, but the container ALWAYS uses the AgentIdentity for outbound calls. So:\n", + "\n", + "- We pin `AZURE_CLIENT_ID` to `instance_identity.client_id` (the AgentIdentity, not the blueprint) so `DefaultAzureCredential` inside the container resolves to the runtime SP.\n", + "- Step 5 then grants the AgentIdentity\\'s principal the RBAC roles it needs.\n", + "\n", + "We register a bootstrap v1 to learn the AgentIdentity's client_id, register v2 with `AZURE_CLIENT_ID` set, then delete the bootstrap so the agent is left with a single version.\n", + "\n", + "We inject the env vars the container needs at startup:\n", + "\n", + "- `AZURE_AI_PROJECT_ENDPOINT` - the Foundry project endpoint. `main.py` appends `/openai/v1/` and points the Copilot SDK's `base_url` at `/openai/v1/`, reaching the model over the project's own OpenAI-compatible surface (token audience `ai.azure.com`). The platform also auto-injects this as `FOUNDRY_PROJECT_ENDPOINT`.\n", + "- `AZURE_AI_MODEL_DEPLOYMENT_NAME` - the model deployment name we want as BYOK.\n", + "- `AZURE_CLIENT_ID` - the AgentIdentity client_id (set in Step 4.5).\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "62489af1", + "metadata": {}, + "outputs": [], + "source": [ + "from azure.ai.projects import AIProjectClient\n", + "from azure.ai.projects.models import (\n", + " HostedAgentDefinition,\n", + " ProtocolVersionRecord,\n", + " AgentProtocol,\n", + ")\n", + "from azure.identity import DefaultAzureCredential\n", + "from azure.core.rest import HttpRequest\n", + "\n", + "client = AIProjectClient(\n", + " endpoint=PROJECT_ENDPOINT,\n", + " credential=DefaultAzureCredential(),\n", + " allow_preview=True,\n", + ")\n", + "\n", + "CONTAINER_IMAGE = f\"{ACR_LOGIN_SERVER}/{AGENT_NAME}:latest\"\n", + "\n", + "# Bootstrap registration. The container will not be invokable yet because\n", + "# AZURE_CLIENT_ID is unset and roles will not be granted until Step 5, but\n", + "# this gives us the per-version AgentIdentity client_id that we need in 4.5.\n", + "agent = client.agents.create_version(\n", + " agent_name=AGENT_NAME,\n", + " definition=HostedAgentDefinition(\n", + " container_protocol_versions=[\n", + " ProtocolVersionRecord(protocol=AgentProtocol.INVOCATIONS, version=\"1.0.0\"),\n", + " ],\n", + " # 2 vCPU / 4Gi: the Copilot SDK runs multi-step shell+python turns (Step 7) that recycle a 1 CPU / 2Gi container.\n", + " cpu=\"2\",\n", + " memory=\"4Gi\",\n", + " image=CONTAINER_IMAGE,\n", + " environment_variables={\n", + " \"AZURE_AI_PROJECT_ENDPOINT\": PROJECT_ENDPOINT,\n", + " \"AZURE_AI_MODEL_DEPLOYMENT_NAME\": DEPLOYMENT_NAME,\n", + " },\n", + " ),\n", + ")\n", + "print(f\"Bootstrap version registered: {agent.name} v{agent.version}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "899b43ea", + "metadata": {}, + "source": [ + "### Step 4.5 - Re-register with `AZURE_CLIENT_ID` pinned\n", + "\n", + "A Foundry hosted agent runs the container with two managed identities attached (`blueprint` + per-version `instance_identity`). Inside the container, `DefaultAzureCredential` has no way to pick between them and throws on `.get_token()`, which surfaces as a generic 500 with body `Internal Server Error`.\n", + "\n", + "The workaround is to pin `AZURE_CLIENT_ID` to the `instance_identity.client_id`. But we only know that ID after registering, so we read it back from the bootstrap version and register a new version with it set. We then delete the bootstrap version (`delete_version`) so the agent is left with just the invokable v2.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "85308b16", + "metadata": {}, + "outputs": [], + "source": [ + "# Read the runtime AgentIdentity\\'s client_id from the bootstrap version.\n", + "# Note: the metadata calls this `instance_identity`. The `blueprint` field is\n", + "# a TEMPLATE identity used by the platform for provisioning, not the runtime\n", + "# SP. Roles must be granted to AgentIdentity (this field), not Blueprint.\n", + "bootstrap_version = agent.version # v1 from Step 4; capture before agent is reassigned to v2 below\n", + "v_meta = json.loads(\n", + " client.send_request(\n", + " HttpRequest(\"GET\", f\"/agents/{AGENT_NAME}/versions/{agent.version}?api-version=v1\")\n", + " ).text()\n", + ")\n", + "AGENT_IDENTITY_CLIENT_ID = v_meta[\"instance_identity\"][\"client_id\"]\n", + "print(f\"Pinning AZURE_CLIENT_ID = {AGENT_IDENTITY_CLIENT_ID} (AgentIdentity)\")\n", + "\n", + "# Register the version we will actually invoke\n", + "agent = client.agents.create_version(\n", + " agent_name=AGENT_NAME,\n", + " definition=HostedAgentDefinition(\n", + " container_protocol_versions=[\n", + " ProtocolVersionRecord(protocol=AgentProtocol.INVOCATIONS, version=\"1.0.0\"),\n", + " ],\n", + " cpu=\"2\",\n", + " memory=\"4Gi\",\n", + " image=CONTAINER_IMAGE,\n", + " environment_variables={\n", + " \"AZURE_AI_PROJECT_ENDPOINT\": PROJECT_ENDPOINT,\n", + " \"AZURE_AI_MODEL_DEPLOYMENT_NAME\": DEPLOYMENT_NAME,\n", + " \"AZURE_CLIENT_ID\": AGENT_IDENTITY_CLIENT_ID,\n", + " },\n", + " ),\n", + ")\n", + "print(f\"Active version: {agent.name} v{agent.version}\")\n", + "print(f\"Image: {CONTAINER_IMAGE}\")\n", + "\n", + "# The bootstrap (v1) existed only to surface the AgentIdentity client_id pinned above.\n", + "# v2 now carries AZURE_CLIENT_ID and is the version we invoke, so delete v1 to leave the\n", + "# agent with a single clean version. (The per-agent identity is stable across versions, so\n", + "# dropping v1 does not affect v2.)\n", + "client.agents.delete_version(AGENT_NAME, agent_version=bootstrap_version)\n", + "print(f\"Deleted bootstrap v{bootstrap_version}; agent now has the single version v{agent.version}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "63292b8a", + "metadata": {}, + "source": [ + "## Step 5 - Grant runtime roles to the per-agent managed identity\n", + "\n", + "Foundry creates a **per-agent managed identity** (the AgentIdentity from Step 4) at registration time, and the container assumes it at runtime. We grant it four roles:\n", + "\n", + "- `AcrPull` on the **ACR** - so the platform can pull the image.\n", + "- `Foundry User` (`53ca6127-...`, formerly \"Azure AI User\") on the **project** - the role that enables the model call: `main.py` reaches `/openai/v1/responses` with the `ai.azure.com` audience, which maps to project data-plane access.\n", + "- `Cognitive Services OpenAI User` (`5e0bd9bd-...`) on the **account** - a safety net left over from the earlier account-endpoint design.\n", + "- `Cognitive Services User` (`a97b65f3-...`) on the **account** - a broader safety net; once the agent works against the project endpoint you can drop these two account-scoped roles.\n", + "\n", + "We read the AgentIdentity's principal ID from the version metadata via the SDK's raw-request escape hatch, then use `az role assignment create`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "61d2d051", + "metadata": {}, + "outputs": [], + "source": [ + "from azure.core.rest import HttpRequest\n", + "\n", + "# Re-read the AgentIdentity principal from the active version (same as 4.5).\n", + "v = json.loads(\n", + " client.send_request(\n", + " HttpRequest(\"GET\", f\"/agents/{AGENT_NAME}/versions/{agent.version}?api-version=v1\")\n", + " ).text()\n", + ")\n", + "AGENT_IDENTITY_PRINCIPAL_ID = v[\"instance_identity\"][\"principal_id\"]\n", + "print(f\"AgentIdentity principal: {AGENT_IDENTITY_PRINCIPAL_ID}\")\n", + "\n", + "acr_id = subprocess.check_output(\n", + " f\"az acr show -n {ACR_NAME} --query id -o tsv\", shell=True, text=True,\n", + ").strip()\n", + "account_id = (\n", + " f\"/subscriptions/{SUBSCRIPTION_ID}/resourceGroups/{RESOURCE_GROUP}\"\n", + " f\"/providers/Microsoft.CognitiveServices/accounts/{ACCOUNT_NAME}\"\n", + ")\n", + "project_id = f\"{account_id}/projects/{PROJECT_NAME}\"\n", + "\n", + "# Role IDs:\n", + "FOUNDRY_USER = \"53ca6127-db72-4b80-b1b0-d745d6d5456d\" # general Foundry data plane\n", + "COG_SVCS_OPENAI_USER = \"5e0bd9bd-7b93-4f28-af87-19fc36ad61bd\" # OpenAI deployments/responses/*\n", + "COG_SVCS_USER = \"a97b65f3-24c7-4388-baec-2e87135dc908\" # generic Cognitive Services data\n", + "\n", + "grants = [\n", + " (\"AcrPull\", acr_id), # pull the container image\n", + " (FOUNDRY_USER, project_id), # general project data plane\n", + " (COG_SVCS_OPENAI_USER, account_id), # /openai/v1/responses on the account\n", + " (COG_SVCS_USER, account_id), # broader Cognitive Services data plane (safety net)\n", + "]\n", + "for role, scope in grants:\n", + " r = subprocess.run(\n", + " f\"az role assignment create --assignee-object-id {AGENT_IDENTITY_PRINCIPAL_ID} \"\n", + " f\"--assignee-principal-type ServicePrincipal --role '{role}' --scope '{scope}'\",\n", + " shell=True, capture_output=True, text=True,\n", + " )\n", + " if r.returncode == 0:\n", + " print(f\" granted: {role}\")\n", + " elif \"already exist\" in r.stderr.lower():\n", + " print(f\" already granted: {role}\")\n", + " else:\n", + " print(f\" failed: {role} - {r.stderr.strip()[:200]}\")\n", + "\n", + "print()\n", + "print(\"Waiting 90s for RBAC + container cold start to settle...\")\n", + "import time; time.sleep(90)\n", + "print(\"Done. Continue to Step 6.\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "ce5c8cfd", + "metadata": {}, + "source": [ + "## Step 6 - Smoke-test the agent\n", + "\n", + "The agent speaks the Invocations protocol over `POST .../endpoint/protocols/invocations`. We build a small `invoke()` helper that POSTs `{\"input\": \"...\"}`, parses the SSE stream, accumulates the assistant deltas, and captures the `session_id` from the terminal event that carries it. The same helper is reused in Step 7 for the CSV demo - session threading there is just `invoke(..., session_id=SESSION_ID)`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5e722ad4", + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "import time\n", + "import requests\n", + "from IPython.display import Markdown, display\n", + "\n", + "API_VERSION = \"v1\"\n", + "\n", + "def _bearer():\n", + " return DefaultAzureCredential().get_token(\"https://ai.azure.com/.default\").token\n", + "\n", + "def invoke(input_text, session_id=None, stream=True, render=False, retries=5, retry_delay=30):\n", + " \"\"\"POST to the agent. Streams SSE assistant deltas; returns (text, session_id).\n", + "\n", + " With render=True the reply is rendered live as Markdown (so tables become real\n", + " tables) instead of raw streamed text - use it for the analytics prompts below.\n", + "\n", + " Retries on 5xx and on mid-stream `error` events (the container warming up,\n", + " RBAC propagating, or being recycled mid-turn). Default budget is 5 retries x 30s = 150s, enough to cover\n", + " typical RBAC propagation + container cold start. On final failure, prints\n", + " the response body before raising.\n", + " \"\"\"\n", + " base = (\n", + " f\"{PROJECT_ENDPOINT}/agents/{AGENT_NAME}/endpoint/protocols/\"\n", + " f\"invocations?api-version={API_VERSION}\"\n", + " )\n", + " url = base if session_id is None else f\"{base}&agent_session_id={session_id}\"\n", + " body = json.dumps({\"input\": input_text})\n", + " last_status, last_body = None, None\n", + " md_handle = display(Markdown(\"\"), display_id=True) if render else None\n", + " for attempt in range(1, retries + 1):\n", + " headers = {\n", + " \"Authorization\": f\"Bearer {_bearer()}\",\n", + " \"Foundry-Features\": \"HostedAgents=V1Preview\",\n", + " \"Content-Type\": \"application/json\",\n", + " }\n", + " resp = requests.post(url, headers=headers, data=body, stream=True)\n", + " if resp.status_code >= 500 and attempt < retries:\n", + " # Read body now (before stream is consumed) so we can show it if needed.\n", + " last_status, last_body = resp.status_code, resp.text[:500]\n", + " print(\n", + " f\"[invoke] {resp.status_code} from server (attempt {attempt}/{retries}); \"\n", + " f\"retrying in {retry_delay}s...\"\n", + " )\n", + " time.sleep(retry_delay)\n", + " continue\n", + " if not resp.ok:\n", + " print(f\"[invoke] HTTP {resp.status_code}\")\n", + " print(f\"[invoke] body: {resp.text[:1500]}\")\n", + " resp.raise_for_status()\n", + " # Success - stream SSE\n", + " chunks, out_sid, stream_error = [], session_id, None\n", + " for raw in resp.iter_lines(decode_unicode=True):\n", + " if not raw or not raw.startswith(\"data:\"):\n", + " continue\n", + " try:\n", + " event = json.loads(raw[5:].strip())\n", + " except json.JSONDecodeError:\n", + " continue\n", + " etype = event.get(\"type\")\n", + " if etype == \"assistant.message_delta\":\n", + " delta = event.get(\"data\", {}).get(\"deltaContent\") or \"\"\n", + " chunks.append(delta)\n", + " if render:\n", + " if \"\\n\" in delta:\n", + " md_handle.update(Markdown(\"\".join(chunks)))\n", + " elif stream:\n", + " sys.stdout.write(delta)\n", + " sys.stdout.flush()\n", + " elif etype == \"error\":\n", + " stream_error = event.get(\"message\")\n", + " break\n", + " elif \"session_id\" in event and \"invocation_id\" in event:\n", + " out_sid = event[\"session_id\"]\n", + " # A mid-stream error (container recycled, gateway reset, etc.) means the turn\n", + " # produced no usable answer: retry if attempts remain, else fail loudly.\n", + " if stream_error:\n", + " if attempt < retries:\n", + " last_status, last_body = \"stream-error\", stream_error[:500]\n", + " print(f\"\\n[invoke] agent error mid-stream (attempt {attempt}/{retries}); retrying in {retry_delay}s...\")\n", + " time.sleep(retry_delay)\n", + " continue\n", + " print(f\"\\n[agent error] {stream_error}\", file=sys.stderr)\n", + " raise RuntimeError(f\"Agent invocation failed after {retries} attempts (mid-stream error): {stream_error[:200]}\")\n", + " if render:\n", + " md_handle.update(Markdown(\"\".join(chunks)))\n", + " elif stream:\n", + " print()\n", + " return \"\".join(chunks), out_sid\n", + " # Exhausted retries\n", + " print(f\"[invoke] exhausted {retries} retries. Last status: {last_status}\")\n", + " print(f\"[invoke] last body: {last_body}\")\n", + " raise RuntimeError(f\"Agent invocation failed after {retries} retries (status={last_status}).\")\n", + "\n", + "def upload_session_file(session_id, local_path, target_path):\n", + " \"\"\"PUT a local file into the session sandbox.\"\"\"\n", + " url = (\n", + " f\"{PROJECT_ENDPOINT}/agents/{AGENT_NAME}/endpoint/sessions/{session_id}\"\n", + " f\"/files/content?api-version={API_VERSION}&path={target_path}\"\n", + " )\n", + " headers = {\n", + " \"Authorization\": f\"Bearer {_bearer()}\",\n", + " \"Foundry-Features\": \"HostedAgents=V1Preview\",\n", + " \"Content-Type\": \"application/octet-stream\",\n", + " }\n", + " with open(local_path, \"rb\") as f:\n", + " r = requests.put(url, headers=headers, data=f.read())\n", + " r.raise_for_status()\n", + " return r\n", + "\n", + "\n", + "def download_session_file(session_id, remote_path, local_path):\n", + " \"\"\"GET a file out of the session sandbox (the read counterpart to upload_session_file).\"\"\"\n", + " url = (\n", + " f\"{PROJECT_ENDPOINT}/agents/{AGENT_NAME}/endpoint/sessions/{session_id}\"\n", + " f\"/files/content?api-version={API_VERSION}&path={remote_path}\"\n", + " )\n", + " headers = {\n", + " \"Authorization\": f\"Bearer {_bearer()}\",\n", + " \"Foundry-Features\": \"HostedAgents=V1Preview\",\n", + " }\n", + " r = requests.get(url, headers=headers)\n", + " r.raise_for_status()\n", + " with open(local_path, \"wb\") as f:\n", + " f.write(r.content)\n", + " return len(r.content)\n", + "\n", + "\n", + "def show_agent_status():\n", + " \"\"\"Pretty-print the current agent version status from the project data plane.\"\"\"\n", + " from azure.core.rest import HttpRequest\n", + " raw = client.send_request(\n", + " HttpRequest(\"GET\", f\"/agents/{AGENT_NAME}/versions/{agent.version}?api-version={API_VERSION}\")\n", + " ).text()\n", + " info = json.loads(raw)\n", + " # Surface the bits that actually matter for \"is it healthy\"\n", + " summary = {\n", + " \"name\": info.get(\"name\"),\n", + " \"version\": info.get(\"version\"),\n", + " \"status\": info.get(\"status\"),\n", + " \"provisioning_state\": info.get(\"provisioning_state\"),\n", + " \"image\": info.get(\"definition\", {}).get(\"image\"),\n", + " \"instance_identity\": info.get(\"instance_identity\"),\n", + " \"endpoint\": info.get(\"endpoint\"),\n", + " \"last_error\": info.get(\"last_error\") or info.get(\"error\"),\n", + " }\n", + " print(json.dumps(summary, indent=2, default=str))\n", + " return info\n", + "\n", + "print(\"Helpers defined: invoke(), upload_session_file(), download_session_file(), show_agent_status().\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "feadead4", + "metadata": {}, + "outputs": [], + "source": [ + "# Smoke test 1 - general capability question.\n", + "invoke(\"What can you help me with? Be brief.\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fb1fddc8", + "metadata": {}, + "outputs": [], + "source": [ + "# Smoke test 2 - exercises the agent's built-in shell/Python tools (no CSV needed).\n", + "invoke(\"Use your shell tools to print the current date and your Python version, then summarise in one line.\")" + ] + }, + { + "cell_type": "markdown", + "id": "tshoot-md-0810", + "metadata": {}, + "source": [ + "### Troubleshooting cold starts\n", + "\n", + "The first invocations can return `500 Internal Server Error` while the container cold-starts and RBAC propagates - `invoke()` already retries through that window. If it keeps failing, inspect the deployed version directly: `provisioning_state`, `last_error`, and the resolved `instance_identity` are the fields that say whether it is still starting, mis-identified, or missing a role." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "tshoot-code-0810", + "metadata": {}, + "outputs": [], + "source": [ + "# Run this if a smoke test keeps returning 500 after invoke() exhausts its retries.\n", + "show_agent_status()" + ] + }, + { + "cell_type": "markdown", + "id": "402e6524", + "metadata": {}, + "source": [ + "## Step 7 - CSV analytics: M365 license cleanup demo\n", + "\n", + "Hosted-agent sessions give every conversation a **persistent sandbox** with `$HOME` and an uploaded-files area. The Copilot SDK has shell + Python tools built-in, so once we upload a file, the agent can `awk` / `python` / `jq` over it across as many turns as we want.\n", + "\n", + "### How the CSV reaches the agent (two distinct things)\n", + "\n", + "**1. The CSV is uploaded to the session sandbox** in 7.2 via a single `PUT` call:\n", + "\n", + "```\n", + "PUT {PROJECT_ENDPOINT}/agents/github-copilot/endpoint/sessions/{SESSION_ID}/files/content\n", + " ?api-version=v1\n", + " &path=m365-licenses.csv\n", + "Content-Type: application/octet-stream\n", + "\n", + "```\n", + "\n", + "The file now lives in the agent container\\'s session-scoped filesystem (under `$HOME` or `/files/`, depending on platform mapping).\n", + "\n", + "**2. The agent is _told_ to use it** by passing `session_id=SESSION_ID` on every subsequent `invoke()` call, which adds `?agent_session_id={SESSION_ID}` to the invocation URL. That query parameter is what routes the request to the same sandbox that has the CSV in it. The prompt body just says \"Using the CSV `m365-licenses.csv`, ...\" - the agent uses its built-in shell tool to `cat` / `awk` / `pandas` the file on its own filesystem.\n", + "\n", + "**The prompt never contains the CSV.** The CSV is uploaded once, sits in the sandbox, and every prompt is just a sentence that references its filename. Drop the `session_id=SESSION_ID` argument (or pass a different one) and the agent lands in a fresh sandbox that does not have the file - it can\\'t answer.\n", + "\n", + "### What the cells below do\n", + "\n", + "1. Make a warm-up invocation to create a session. The terminal `done` SSE event carries `session_id`.\n", + "2. Upload `data/m365-licenses.csv` (100 synthetic users, real Microsoft license SKUs, engineered outliers) **and** `data/m365-reference.json` (per-SKU costs + department-code names) into that session via the [REST file-ops endpoint](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/manage-hosted-sessions?pivots=rest#session-file-operations). The skill supplies the analysis *method*; this reference supplies the *data*.\n", + "3. Bind every follow-up invocation to that session ID with `?agent_session_id=` so the sandbox keeps the file across turns.\n", + "4. Ask the agent five analytics questions - simple counts, filtered lookups, cross-filtered cost analysis, and an optimisation recommendation - and stream each answer inline.\n", + "5. Have the agent render a cost-by-department chart, save it into the sandbox, and download it back with the `download_session_file()` GET helper - the read counterpart to the upload in step 2.\n", + "\n", + "The dataset\\'s columns: `UserPrincipalName, sAMAccountName, DisplayName, Ext.5 (license SKU), Ext.6 (department code 1-8), Ext.3 (cost-centre flag), whenCreated, LastSignInDateTime, AccountEnabled`. Engineered outliers include 5 disabled-but-licensed accounts, 6 SPE_E5 holders inactive 90+ days, and several never-signed-in ghosts.\n" + ] + }, + { + "cell_type": "markdown", + "id": "648c11e9", + "metadata": {}, + "source": [ + "### 7.1 Warm-up - create a session, capture its `session_id`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8ca53d6e", + "metadata": {}, + "outputs": [], + "source": [ + "print(\"--- warm-up ---\")\n", + "_, SESSION_ID = invoke(\n", + " \"We're about to analyse an M365 license CSV. Reply with one word: ready.\"\n", + ")\n", + "print(f\"\\nsession_id = {SESSION_ID}\\n\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "3ecddf9e", + "metadata": {}, + "source": [ + "### 7.2 Upload the CSV and the cost/department reference into the session sandbox" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "af2824cb", + "metadata": {}, + "outputs": [], + "source": [ + "CSV_LOCAL = \"data/m365-licenses.csv\"\n", + "CSV_REMOTE = \"m365-licenses.csv\"\n", + "REF_LOCAL = \"data/m365-reference.json\"\n", + "REF_REMOTE = \"m365-reference.json\"\n", + "\n", + "for local, remote in [(CSV_LOCAL, CSV_REMOTE), (REF_LOCAL, REF_REMOTE)]:\n", + " upload_session_file(SESSION_ID, local, remote)\n", + " print(f\"Uploaded {local} ({pathlib.Path(local).stat().st_size} bytes) -> session {SESSION_ID} as {remote}\")\n", + "print()\n", + "\n", + "print(\"--- agent confirms it can see the files ---\")\n", + "_ = invoke(\n", + " f\"Two files were just uploaded to your session: the data `{CSV_REMOTE}` and the reference `{REF_REMOTE}`. \"\n", + " \"Locate them (try `find $HOME /files -name '*.csv' -o -name '*.json' 2>/dev/null`), run `head -2` on the CSV, \"\n", + " \"and print the top-level keys of the JSON. Reply with the two paths and the CSV header line. Be terse.\",\n", + " session_id=SESSION_ID,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "e9efb745", + "metadata": {}, + "source": [ + "### 7.3 Prompt 1 - License count per SKU" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f491c40f", + "metadata": {}, + "outputs": [], + "source": [ + "print(\"\\n=== Q1: License count per SKU ===\")\n", + "_ = invoke(\n", + " f\"Using the CSV `{CSV_REMOTE}`, return a markdown table of users per license SKU (column `Ext.5`), \"\n", + " \"sorted by count descending. Columns: License SKU, User count. No commentary, table only.\",\n", + " session_id=SESSION_ID,\n", + " render=True,\n", + ")\n" + ] + }, + { + "cell_type": "markdown", + "id": "994c7a09", + "metadata": {}, + "source": [ + "### 7.4 Prompt 2 - Stale SPE_E5 holders (90+ days inactive)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cb88f65a", + "metadata": {}, + "outputs": [], + "source": [ + "print(\"\\n=== Q2: SPE_E5 holders inactive 90+ days ===\")\n", + "_ = invoke(\n", + " f\"Using `{CSV_REMOTE}`, list every user whose `Ext.5` is `SPE_E5` AND `AccountEnabled` is `TRUE` \"\n", + " \"AND `LastSignInDateTime` is non-empty AND older than 90 days from today (2026-05-28). \"\n", + " \"Return a markdown table: UserPrincipalName, DisplayName, LastSignInDateTime, days_since_signin, Ext.6. \"\n", + " \"Sort by days_since_signin descending. End with a one-line summary of total count.\",\n", + " session_id=SESSION_ID,\n", + " render=True,\n", + ")\n" + ] + }, + { + "cell_type": "markdown", + "id": "54638c71", + "metadata": {}, + "source": [ + "### 7.5 Prompt 3 - Disabled accounts still holding a license" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "81de9e25", + "metadata": {}, + "outputs": [], + "source": [ + "print(\"\\n=== Q3: Disabled accounts still licensed ===\")\n", + "_ = invoke(\n", + " f\"Using `{CSV_REMOTE}`, list every row where `AccountEnabled` is `FALSE`. \"\n", + " \"Return a markdown table: UserPrincipalName, Ext.5 (license SKU), Ext.6 (department code), whenCreated. \"\n", + " \"End with: 'Cleanup candidates: '.\",\n", + " session_id=SESSION_ID,\n", + " render=True,\n", + ")\n" + ] + }, + { + "cell_type": "markdown", + "id": "0d99571e", + "metadata": {}, + "source": [ + "### 7.6 Prompt 4 - Cross-filter: monthly cost per department\n", + "\n", + "The costs and department-code map are no longer in the prompt *or* the skill - they come from the uploaded `m365-reference.json`. The `m365-license-analytics` skill supplies only the analysis method." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2750f463", + "metadata": {}, + "outputs": [], + "source": [ + "print(\"\\n=== Q4: Monthly M365 cost per department ===\")\n", + "_ = invoke(\n", + " f\"Using `{CSV_REMOTE}` joined to the costs and department names in `{REF_REMOTE}`, \"\n", + " \"compute total monthly license spend per department. Return a markdown table sorted by \"\n", + " \"total cost descending: Department, User count, Monthly cost (USD). End with the grand total.\",\n", + " session_id=SESSION_ID,\n", + " render=True,\n", + ")\n" + ] + }, + { + "cell_type": "markdown", + "id": "52cc35d2", + "metadata": {}, + "source": [ + "### 7.7 Prompt 5 - Optimisation recommendation (reclaim plan)\n", + "\n", + "Likewise, the per-SKU costs come from `m365-reference.json`; the reclaim-rule definition comes from the skill." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ecac3401", + "metadata": {}, + "outputs": [], + "source": [ + "print(\"\\n=== Q5: Reclaim plan - who to deprovision and how much we'd save ===\")\n", + "_ = invoke(\n", + " f\"Using `{CSV_REMOTE}`, find every reclaim candidate per your reclaim rules (today is 2026-05-28), \"\n", + " f\"costing each license from `{REF_REMOTE}`. \"\n", + " \"Output exactly:\\n\"\n", + " \"1. A one-line summary: 'Total reclaimable: users, USD/month'.\\n\"\n", + " \"2. A markdown table sorted by Monthly savings descending: \"\n", + " \"UserPrincipalName, Reason, License (Ext.5), Monthly savings (USD).\\n\"\n", + " \"Be precise about the math.\",\n", + " session_id=SESSION_ID,\n", + " render=True,\n", + ")\n" + ] + }, + { + "cell_type": "markdown", + "id": "c4a17e10", + "metadata": {}, + "source": [ + "### 7.8 - Visualise: chart cost by department, then download it from the agent\n", + "\n", + "The session sandbox is read **and** write, so the agent can produce artefacts we pull back out. Here it computes monthly cost per department (as in 7.6), renders it to a chart **file** saved inside the sandbox, and we retrieve that file with a `download_session_file()` GET helper - the read counterpart to the `upload_session_file()` PUT from 7.2.\n", + "\n", + "The agent picks its own tooling. We prompt it to use **matplotlib**, which it will `pip install` into its own session shell if the package is not already present; if the container has no package egress, it falls back to hand-writing a dependency-free **SVG** bar chart. Either way it saves the chart under a known filename and reports it on a `CHART_FILE:` line that we parse to know what to download. If the file API cannot serve an agent-written file on your platform build, the cell falls back to having the agent return the SVG inline." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c4a17e11", + "metadata": {}, + "outputs": [], + "source": [ + "import re\n", + "from IPython.display import Image, SVG, display\n", + "\n", + "print(\"\\n=== Q6: render a cost-by-department chart and save it to the sandbox ===\")\n", + "chart_answer, _ = invoke(\n", + " f\"Using `{CSV_REMOTE}` joined to `{REF_REMOTE}`, compute total monthly license spend per department \"\n", + " \"(department names come from the reference). Render a horizontal bar chart: department on the y-axis, \"\n", + " \"monthly USD on the x-axis, bars sorted by cost descending, each bar labelled with its dollar value, \"\n", + " \"title 'M365 monthly license cost by department'. \"\n", + " \"Save the chart into the SAME directory as the uploaded CSV. \"\n", + " \"Prefer matplotlib with a non-interactive backend (Agg); if it is not importable, pip install it into your \"\n", + " \"session shell; if you have no network to install packages, hand-write a self-contained SVG bar chart instead. \"\n", + " \"Name the file `license_cost_by_department.png` (matplotlib) or `license_cost_by_department.svg` (SVG fallback). \"\n", + " \"After saving, reply with EXACTLY one line and nothing else: `CHART_FILE: `.\",\n", + " session_id=SESSION_ID,\n", + ")\n", + "\n", + "m = re.search(r\"CHART_FILE:\\s*`?([^\\s`]+)\", chart_answer)\n", + "if not m:\n", + " raise RuntimeError(f\"Agent did not report a CHART_FILE line. Full reply:\\n{chart_answer}\")\n", + "chart_remote = m.group(1)\n", + "chart_local = f\"data/{chart_remote}\"\n", + "\n", + "try:\n", + " n = download_session_file(SESSION_ID, chart_remote, chart_local)\n", + " print(f\"\\nDownloaded {chart_remote} -> {chart_local} ({n} bytes)\")\n", + " display(SVG(filename=chart_local) if chart_remote.endswith(\".svg\") else Image(filename=chart_local))\n", + "except Exception as exc:\n", + " # Some platform builds do not serve agent-written files through the file API.\n", + " # Fall back to having the agent return the chart inline as SVG (always works over SSE).\n", + " print(f\"\\n[download] could not GET {chart_remote} ({exc}); asking the agent to inline the SVG instead...\")\n", + " svg_answer, _ = invoke(\n", + " \"Re-render that same department-cost bar chart as one self-contained SVG and output ONLY the raw \"\n", + " \"SVG markup, starting with - no code fences, no commentary.\",\n", + " session_id=SESSION_ID,\n", + " )\n", + " sm = re.search(r\"\", svg_answer, re.S)\n", + " if not sm:\n", + " raise RuntimeError(f\"No inline SVG in fallback reply:\\n{svg_answer[:600]}\")\n", + " chart_local = \"data/license_cost_by_department.svg\"\n", + " with open(chart_local, \"w\", encoding=\"utf-8\") as f:\n", + " f.write(sm.group(0))\n", + " print(f\"Saved inline SVG -> {chart_local}\")\n", + " display(SVG(data=sm.group(0)))" + ] + }, + { + "cell_type": "markdown", + "id": "87caa3d8", + "metadata": {}, + "source": [ + "### Optional - delete the uploaded file from the session sandbox\n", + "\n", + "The file persists for the life of the session (up to 30 days or until the session is explicitly deleted). To remove just the CSV without tearing down the session:\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1651f1b9", + "metadata": {}, + "outputs": [], + "source": [ + "# Uncomment to delete the CSV from the session sandbox.\n", + "# delete_url = (\n", + "# f\"{PROJECT_ENDPOINT}/agents/{AGENT_NAME}/endpoint/sessions/{SESSION_ID}\"\n", + "# f\"/files?api-version={API_VERSION}&path={CSV_REMOTE}\"\n", + "# )\n", + "# r = requests.delete(delete_url, headers={\n", + "# \"Authorization\": f\"Bearer {_bearer()}\",\n", + "# \"Foundry-Features\": \"HostedAgents=V1Preview\",\n", + "# })\n", + "# r.raise_for_status()\n", + "# print(f\"Deleted {CSV_REMOTE} from session {SESSION_ID}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "5ebb7f97", + "metadata": {}, + "source": [ + "## Step 8 - Inspect tracing in Foundry portal\n", + "\n", + "`tracing.py` maps each Copilot `SessionEvent` to an OpenTelemetry span and exports it to Application Insights. In **Foundry portal -> your project -> Tracing** you should now see one tree per invocation:\n", + "\n", + "```\n", + "invoke_agent github-copilot (SERVER, parent)\n", + "+-- execute_tool (one per tool / skill call)\n", + "+-- chat gpt-5.4-mini (Tokens In/Out + Cost)\n", + "```\n", + "\n", + "The `chat ` span is what populates the **Tokens (In)**, **Tokens (Out)**, and **Estimated Cost** columns. The CSV analytics turns will have multiple `execute_tool` spans per invocation (one per shell / awk / python call the Copilot SDK made) - those are how the agent actually answered each question.\n" + ] + }, + { + "cell_type": "markdown", + "id": "d19a6a74", + "metadata": {}, + "source": [ + "## Step 9 - Customize the agent\n", + "\n", + "Two clean extension surfaces, no code edits required:\n", + "\n", + "| Knob | When to use | How |\n", + "|---|---|---|\n", + "| `src/github-copilot-invocations/system_prompt.md` | Persona / global policy that applies on every turn | Edit the file, re-run Step 3 (rebuild) + Step 4 (re-register) |\n", + "| `src/github-copilot-invocations/skills//SKILL.md` | Task-specific procedure the model discovers on demand | `mkdir skills/` + write a `SKILL.md`, rebuild + re-register |\n", + "\n", + "`system_prompt.md` is **appended** to the Copilot CLI's built-in system message (CLI guardrails are preserved); leaving it empty falls back to the CLI default. The bundled `skills/m365-license-analytics/SKILL.md` shows the right split: the skill carries the durable analysis *method* and the reclaim-rule definition, while volatile data (SKU costs, department names) is uploaded separately as `m365-reference.json` - so prices never live in the skill. A natural next skill would draft the deprovisioning change-request from the reclaim list." + ] + }, + { + "cell_type": "markdown", + "id": "ff8fc6f6", + "metadata": {}, + "source": [ + "## Cleanup\n", + "\n", + "Tear down the whole resource group when you're done. The cell below is commented intentionally - uncomment when you actually want to tear down.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b9142f80", + "metadata": {}, + "outputs": [], + "source": [ + "# Uncomment to delete every resource we provisioned.\n", + "# sh(f'az group delete --name \"{RESOURCE_GROUP}\" --yes --no-wait')\n", + "# # The AI Services account uses soft-delete; if you want to fully purge it so the name is reusable:\n", + "# # sh(f'az cognitiveservices account purge --location \"{LOCATION}\" --resource-group \"{RESOURCE_GROUP}\" --name \"{ACCOUNT_NAME}\"')\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "awesome-foundry-nextgen", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/08-agents/08-10-hosted-copilot-sdk-agent/data/m365-licenses.csv b/08-agents/08-10-hosted-copilot-sdk-agent/data/m365-licenses.csv new file mode 100644 index 0000000..fb7f307 --- /dev/null +++ b/08-agents/08-10-hosted-copilot-sdk-agent/data/m365-licenses.csv @@ -0,0 +1,101 @@ +UserPrincipalName,sAMAccountName,DisplayName,Ext.5,Ext.6,Ext.3,whenCreated,LastSignInDateTime,AccountEnabled +aiya.walker@contoso.com,aiya.walker,Aiya Walker,SPE_E3,2,1,2021-10-14,2026-04-12T17:00:00Z,TRUE +anika.taylor@contoso.com,anika.taylor,Anika Taylor,SPE_E3,2,0,2023-01-16,2026-05-04T13:33:00Z,TRUE +anthony.young@contoso.com,anthony.young,Anthony Young,ENTERPRISEPACK,5,1,2025-06-18,2026-05-15T15:01:00Z,TRUE +anthony.young2@contoso.com,anthony.young2,Anthony Young,SPE_E3,2,1,2021-09-27,2026-05-23T10:49:00Z,TRUE +ashley.lewis@contoso.com,ashley.lewis,Ashley Lewis,SPE_E5,1,1,2026-02-04,,TRUE +ashley.wilson@contoso.com,ashley.wilson,Ashley Wilson,SPE_E3,3,1,2021-06-14,2026-04-29T15:25:00Z,TRUE +brian.harris@contoso.com,brian.harris,Brian Harris,SPE_E3,5,0,2022-07-13,2026-05-03T20:11:00Z,TRUE +carol.adams@contoso.com,carol.adams,Carol Adams,SPE_E5,1,1,2021-11-24,2025-10-21T18:00:00Z,TRUE +carol.ramirez@contoso.com,carol.ramirez,Carol Ramirez,SPE_E3,2,1,2024-12-05,2026-05-21T09:40:00Z,TRUE +charles.allen@contoso.com,charles.allen,Charles Allen,SPE_E3,6,1,2021-10-18,,TRUE +charles.hernandez@contoso.com,charles.hernandez,Charles Hernandez,ENTERPRISEPACK,4,1,2022-12-16,,TRUE +christopher.carter@contoso.com,christopher.carter,Christopher Carter,SPE_E5,8,1,2023-03-25,,TRUE +christopher.nelson@contoso.com,christopher.nelson,Christopher Nelson,SPE_E5,8,1,2022-08-25,2026-05-15T01:42:00Z,TRUE +christopher.taylor@contoso.com,christopher.taylor,Christopher Taylor,SPE_E3,2,1,2024-03-02,2026-05-07T14:31:00Z,TRUE +christopher.young@contoso.com,christopher.young,Christopher Young,SPE_E5,1,1,2021-09-21,2026-04-11T23:00:00Z,TRUE +daniel.hall@contoso.com,daniel.hall,Daniel Hall,SPE_E3,4,1,2025-09-16,2026-04-06T21:00:00Z,TRUE +daniel.wilson@contoso.com,daniel.wilson,Daniel Wilson,M365_F3,6,1,2023-04-01,2025-08-01T05:00:00Z,TRUE +david.robinson@contoso.com,david.robinson,David Robinson,SPE_E5,1,1,2022-05-09,2025-10-14T08:00:00Z,TRUE +deborah.martinez@contoso.com,deborah.martinez,Deborah Martinez,SPE_E5,8,1,2021-11-25,2026-03-01T11:00:00Z,TRUE +deborah.mitchell@contoso.com,deborah.mitchell,Deborah Mitchell,SPE_E5,5,1,2025-02-24,2025-06-21T17:00:00Z,FALSE +deborah.wright@contoso.com,deborah.wright,Deborah Wright,SPE_E5,8,1,2022-03-31,2026-05-04T05:12:00Z,TRUE +donald.adams@contoso.com,donald.adams,Donald Adams,STANDARDPACK,6,1,2025-11-12,2026-05-15T01:45:00Z,TRUE +donna.mitchell@contoso.com,donna.mitchell,Donna Mitchell,SPE_E3,5,0,2023-02-10,2026-04-28T07:50:00Z,TRUE +edward.johnson@contoso.com,edward.johnson,Edward Johnson,SPE_E5,8,1,2022-11-26,2026-05-23T08:18:00Z,TRUE +edward.lopez@contoso.com,edward.lopez,Edward Lopez,SPE_E3,5,1,2024-09-17,2026-03-25T05:00:00Z,TRUE +edward.wilson@contoso.com,edward.wilson,Edward Wilson,M365_F1,6,1,2025-07-04,2026-05-10T21:50:00Z,TRUE +elizabeth.green@contoso.com,elizabeth.green,Elizabeth Green,ENTERPRISEPACK,2,1,2023-01-16,2026-01-24T12:00:00Z,FALSE +elizabeth.taylor@contoso.com,elizabeth.taylor,Elizabeth Taylor,ENTERPRISEPACK,2,1,2022-03-14,2026-04-29T05:35:00Z,TRUE +emily.rodriguez@contoso.com,emily.rodriguez,Emily Rodriguez,SPE_E3,2,1,2025-05-30,2026-05-18T11:25:00Z,TRUE +george.davis@contoso.com,george.davis,George Davis,STANDARDPACK,6,1,2022-01-28,2026-05-24T14:33:00Z,TRUE +george.king@contoso.com,george.king,George King,SPE_E5,8,1,2023-05-15,2026-03-28T11:00:00Z,TRUE +hiroshi.hill@contoso.com,hiroshi.hill,Hiroshi Hill,STANDARDPACK,4,1,2022-02-25,2026-04-14T02:00:00Z,TRUE +hiroshi.robinson@contoso.com,hiroshi.robinson,Hiroshi Robinson,SPE_E5,1,1,2021-12-20,2026-03-20T03:00:00Z,TRUE +james.clark@contoso.com,james.clark,James Clark,ENTERPRISEPACK,4,1,2026-03-16,2026-05-05T14:16:00Z,TRUE +james.king@contoso.com,james.king,James King,SPE_E5,8,1,2024-02-09,2026-05-03T16:16:00Z,TRUE +jessica.baker@contoso.com,jessica.baker,Jessica Baker,SPE_E3,3,0,2026-02-16,2026-05-12T04:06:00Z,TRUE +jessica.carter@contoso.com,jessica.carter,Jessica Carter,ENTERPRISEPACK,4,1,2024-07-02,2026-04-30T13:49:00Z,TRUE +jessica.moore@contoso.com,jessica.moore,Jessica Moore,STANDARDPACK,3,0,2025-07-17,2026-05-05T19:15:00Z,TRUE +jessica.torres@contoso.com,jessica.torres,Jessica Torres,SPE_E5,8,1,2021-10-23,2026-05-03T06:11:00Z,TRUE +john.white@contoso.com,john.white,John White,SPE_E3,5,1,2026-02-25,2026-04-29T22:03:00Z,TRUE +joshua.allen@contoso.com,joshua.allen,Joshua Allen,ENTERPRISEPACK,2,1,2026-02-20,2026-04-26T08:00:00Z,TRUE +joshua.taylor@contoso.com,joshua.taylor,Joshua Taylor,SPE_E5,1,1,2021-09-12,2026-02-15T03:00:00Z,TRUE +karen.carter@contoso.com,karen.carter,Karen Carter,SPE_E5,3,1,2021-09-13,2025-07-26T07:00:00Z,FALSE +karen.garcia@contoso.com,karen.garcia,Karen Garcia,SPE_E5,8,1,2024-01-20,2026-05-02T04:46:00Z,TRUE +karen.nelson@contoso.com,karen.nelson,Karen Nelson,ENTERPRISEPACK,5,1,2024-06-16,2026-05-04T06:39:00Z,TRUE +karen.sanchez@contoso.com,karen.sanchez,Karen Sanchez,SPE_E5,8,1,2023-06-27,2026-05-20T16:56:00Z,TRUE +karen.torres@contoso.com,karen.torres,Karen Torres,SPE_E3,6,1,2026-04-02,,TRUE +kenneth.moore@contoso.com,kenneth.moore,Kenneth Moore,SPE_E3,7,1,2025-01-17,2026-05-25T01:42:00Z,TRUE +kenneth.ramirez@contoso.com,kenneth.ramirez,Kenneth Ramirez,SPE_E5,8,0,2022-01-23,2026-05-19T18:13:00Z,TRUE +kenneth.thomas@contoso.com,kenneth.thomas,Kenneth Thomas,SPE_E3,2,1,2023-01-08,2026-03-07T06:00:00Z,TRUE +kimberly.hill@contoso.com,kimberly.hill,Kimberly Hill,SPE_E3,3,1,2024-09-22,2026-05-22T15:00:00Z,TRUE +liam.adams@contoso.com,liam.adams,Liam Adams,SPE_E3,5,1,2023-04-12,2025-09-25T03:00:00Z,FALSE +liam.gonzalez@contoso.com,liam.gonzalez,Liam Gonzalez,ENTERPRISEPACK,4,1,2025-07-20,2025-12-15T22:00:00Z,TRUE +liam.scott@contoso.com,liam.scott,Liam Scott,SPE_E3,1,1,2025-11-11,,TRUE +liam.thomas@contoso.com,liam.thomas,Liam Thomas,SPE_E5,2,1,2026-04-18,,TRUE +linda.torres@contoso.com,linda.torres,Linda Torres,ENTERPRISEPACK,2,0,2025-12-05,2026-05-09T05:27:00Z,TRUE +linda.torres2@contoso.com,linda.torres2,Linda Torres,SPE_E3,8,1,2022-02-01,2026-05-08T04:13:00Z,TRUE +lisa.carter@contoso.com,lisa.carter,Lisa Carter,SPE_E5,8,1,2021-09-21,2026-05-01T21:30:00Z,TRUE +lisa.johnson@contoso.com,lisa.johnson,Lisa Johnson,SPE_E3,5,1,2026-05-07,2026-05-07T22:46:00Z,TRUE +lisa.lopez@contoso.com,lisa.lopez,Lisa Lopez,SPE_E5,8,1,2023-09-16,2026-05-25T11:29:00Z,TRUE +lisa.wilson@contoso.com,lisa.wilson,Lisa Wilson,M365_F1,6,1,2024-12-23,2026-01-30T22:00:00Z,TRUE +margaret.anderson@contoso.com,margaret.anderson,Margaret Anderson,ENTERPRISEPACK,2,1,2023-04-06,2026-04-30T20:31:00Z,TRUE +mark.lewis@contoso.com,mark.lewis,Mark Lewis,STANDARDPACK,6,1,2021-12-03,2026-03-15T22:00:00Z,TRUE +mark.scott@contoso.com,mark.scott,Mark Scott,STANDARDPACK,6,0,2025-12-18,,TRUE +mary.lopez@contoso.com,mary.lopez,Mary Lopez,M365_F1,6,1,2024-01-29,2026-03-02T01:00:00Z,TRUE +mary.martin@contoso.com,mary.martin,Mary Martin,ENTERPRISEPACK,5,1,2022-05-18,2026-05-09T01:07:00Z,TRUE +mary.mitchell@contoso.com,mary.mitchell,Mary Mitchell,SPE_E3,5,1,2026-01-01,2026-03-16T23:00:00Z,TRUE +mary.nelson@contoso.com,mary.nelson,Mary Nelson,SPE_E3,2,1,2025-12-30,,TRUE +mateo.roberts@contoso.com,mateo.roberts,Mateo Roberts,SPE_E3,5,1,2022-02-04,2026-05-12T23:38:00Z,TRUE +matthew.hill@contoso.com,matthew.hill,Matthew Hill,SPE_E3,8,1,2023-04-14,2026-02-20T03:00:00Z,TRUE +melissa.davis@contoso.com,melissa.davis,Melissa Davis,SPE_E5,7,1,2024-08-22,2026-01-01T06:00:00Z,TRUE +melissa.wright@contoso.com,melissa.wright,Melissa Wright,SPE_E5,2,0,2026-01-30,,TRUE +michael.rodriguez@contoso.com,michael.rodriguez,Michael Rodriguez,SPE_E5,2,1,2025-07-07,2026-05-08T17:15:00Z,TRUE +nancy.anderson@contoso.com,nancy.anderson,Nancy Anderson,SPE_E5,8,1,2025-11-16,2026-03-02T21:00:00Z,TRUE +noah.robinson@contoso.com,noah.robinson,Noah Robinson,ENTERPRISEPACK,8,1,2024-03-06,2026-05-06T23:55:00Z,TRUE +noah.scott@contoso.com,noah.scott,Noah Scott,STANDARDPACK,4,1,2023-09-22,2026-05-03T16:07:00Z,TRUE +noah.smith@contoso.com,noah.smith,Noah Smith,SPE_E3,3,1,2021-12-24,2025-09-14T10:00:00Z,FALSE +olivia.robinson@contoso.com,olivia.robinson,Olivia Robinson,STANDARDPACK,3,1,2023-08-29,2026-05-18T17:57:00Z,TRUE +olivia.williams@contoso.com,olivia.williams,Olivia Williams,ENTERPRISEPACK,3,1,2025-12-31,2026-05-17T23:06:00Z,TRUE +patricia.allen@contoso.com,patricia.allen,Patricia Allen,SPE_E5,1,0,2026-01-02,2026-04-11T12:00:00Z,TRUE +patricia.hernandez@contoso.com,patricia.hernandez,Patricia Hernandez,ENTERPRISEPACK,7,1,2023-03-13,2025-07-16T21:00:00Z,TRUE +priya.wright@contoso.com,priya.wright,Priya Wright,M365_F3,6,1,2025-12-29,,TRUE +richard.martinez@contoso.com,richard.martinez,Richard Martinez,SPE_E3,3,1,2022-01-08,2026-02-25T05:00:00Z,TRUE +richard.miller@contoso.com,richard.miller,Richard Miller,SPE_E5,1,1,2024-01-31,2026-05-04T22:17:00Z,TRUE +richard.wright@contoso.com,richard.wright,Richard Wright,SPE_E3,4,1,2023-10-17,2026-04-28T21:32:00Z,TRUE +robert.torres@contoso.com,robert.torres,Robert Torres,M365_F3,6,1,2025-12-04,,TRUE +robert.williams@contoso.com,robert.williams,Robert Williams,SPE_E5,8,1,2024-10-28,2026-04-13T07:00:00Z,TRUE +robert.wright@contoso.com,robert.wright,Robert Wright,SPE_E3,2,0,2024-06-06,2025-10-23T13:00:00Z,TRUE +sandra.hall@contoso.com,sandra.hall,Sandra Hall,SPE_E5,3,1,2024-11-10,2025-06-14T07:00:00Z,TRUE +sarah.ramirez@contoso.com,sarah.ramirez,Sarah Ramirez,ENTERPRISEPACK,5,1,2022-07-31,2026-03-11T19:00:00Z,TRUE +sarah.thompson@contoso.com,sarah.thompson,Sarah Thompson,M365_F1,6,1,2021-08-28,2026-04-27T23:53:00Z,TRUE +sofia.campbell@contoso.com,sofia.campbell,Sofia Campbell,SPE_E3,5,1,2021-09-23,2025-10-06T10:00:00Z,TRUE +stephanie.jones@contoso.com,stephanie.jones,Stephanie Jones,SPE_E5,4,1,2023-02-28,2025-11-18T02:00:00Z,TRUE +stephanie.king@contoso.com,stephanie.king,Stephanie King,ENTERPRISEPACK,3,1,2023-02-01,,TRUE +susan.jones@contoso.com,susan.jones,Susan Jones,SPE_E3,4,1,2024-01-21,2026-05-01T10:36:00Z,TRUE +susan.torres@contoso.com,susan.torres,Susan Torres,SPE_E5,8,1,2023-08-03,2026-04-28T03:31:00Z,TRUE +william.campbell@contoso.com,william.campbell,William Campbell,ENTERPRISEPACK,8,1,2025-01-09,2026-03-06T11:00:00Z,TRUE +william.green@contoso.com,william.green,William Green,SPE_E3,8,0,2026-04-26,2026-05-12T08:32:00Z,TRUE +william.robinson@contoso.com,william.robinson,William Robinson,STANDARDPACK,6,1,2022-02-25,2025-06-12T11:00:00Z,TRUE +william.rodriguez@contoso.com,william.rodriguez,William Rodriguez,STANDARDPACK,6,1,2025-12-13,,TRUE diff --git a/08-agents/08-10-hosted-copilot-sdk-agent/data/m365-reference.json b/08-agents/08-10-hosted-copilot-sdk-agent/data/m365-reference.json new file mode 100644 index 0000000..ecd3d0b --- /dev/null +++ b/08-agents/08-10-hosted-copilot-sdk-agent/data/m365-reference.json @@ -0,0 +1,20 @@ +{ + "license_costs_usd_monthly": { + "SPE_E5": 57.00, + "SPE_E3": 36.00, + "ENTERPRISEPACK": 23.00, + "STANDARDPACK": 10.00, + "M365_F3": 8.00, + "M365_F1": 2.25 + }, + "department_codes": { + "1": "IT", + "2": "Sales", + "3": "Marketing", + "4": "HR", + "5": "Finance", + "6": "Operations", + "7": "Legal", + "8": "Engineering" + } +} diff --git a/08-agents/08-09/README.md b/08-agents/08-10-hosted-copilot-sdk-agent/infra/abbreviations.json similarity index 97% rename from 08-agents/08-09/README.md rename to 08-agents/08-10-hosted-copilot-sdk-agent/infra/abbreviations.json index 00cef3f..879b2a9 100644 --- a/08-agents/08-09/README.md +++ b/08-agents/08-10-hosted-copilot-sdk-agent/infra/abbreviations.json @@ -1,137 +1,137 @@ -{ - "aiFoundryAccounts": "aif", - "analysisServicesServers": "as", - "apiManagementService": "apim-", - "appConfigurationStores": "appcs-", - "appManagedEnvironments": "cae-", - "appContainerApps": "ca-", - "authorizationPolicyDefinitions": "policy-", - "automationAutomationAccounts": "aa-", - "blueprintBlueprints": "bp-", - "blueprintBlueprintsArtifacts": "bpa-", - "cacheRedis": "redis-", - "cdnProfiles": "cdnp-", - "cdnProfilesEndpoints": "cdne-", - "cognitiveServicesAccounts": "cog-", - "cognitiveServicesFormRecognizer": "cog-fr-", - "cognitiveServicesTextAnalytics": "cog-ta-", - "computeAvailabilitySets": "avail-", - "computeCloudServices": "cld-", - "computeDiskEncryptionSets": "des", - "computeDisks": "disk", - "computeDisksOs": "osdisk", - "computeGalleries": "gal", - "computeSnapshots": "snap-", - "computeVirtualMachines": "vm", - "computeVirtualMachineScaleSets": "vmss-", - "containerInstanceContainerGroups": "ci", - "containerRegistryRegistries": "cr", - "containerServiceManagedClusters": "aks-", - "databricksWorkspaces": "dbw-", - "dataFactoryFactories": "adf-", - "dataLakeAnalyticsAccounts": "dla", - "dataLakeStoreAccounts": "dls", - "dataMigrationServices": "dms-", - "dBforMySQLServers": "mysql-", - "dBforPostgreSQLServers": "psql-", - "devicesIotHubs": "iot-", - "devicesProvisioningServices": "provs-", - "devicesProvisioningServicesCertificates": "pcert-", - "documentDBDatabaseAccounts": "cosmos-", - "documentDBMongoDatabaseAccounts": "cosmon-", - "eventGridDomains": "evgd-", - "eventGridDomainsTopics": "evgt-", - "eventGridEventSubscriptions": "evgs-", - "eventHubNamespaces": "evhns-", - "eventHubNamespacesEventHubs": "evh-", - "hdInsightClustersHadoop": "hadoop-", - "hdInsightClustersHbase": "hbase-", - "hdInsightClustersKafka": "kafka-", - "hdInsightClustersMl": "mls-", - "hdInsightClustersSpark": "spark-", - "hdInsightClustersStorm": "storm-", - "hybridComputeMachines": "arcs-", - "insightsActionGroups": "ag-", - "insightsComponents": "appi-", - "keyVaultVaults": "kv-", - "kubernetesConnectedClusters": "arck", - "kustoClusters": "dec", - "kustoClustersDatabases": "dedb", - "logicIntegrationAccounts": "ia-", - "logicWorkflows": "logic-", - "machineLearningServicesWorkspaces": "mlw-", - "managedIdentityUserAssignedIdentities": "id-", - "managementManagementGroups": "mg-", - "migrateAssessmentProjects": "migr-", - "networkApplicationGateways": "agw-", - "networkApplicationSecurityGroups": "asg-", - "networkAzureFirewalls": "afw-", - "networkBastionHosts": "bas-", - "networkConnections": "con-", - "networkDnsZones": "dnsz-", - "networkExpressRouteCircuits": "erc-", - "networkFirewallPolicies": "afwp-", - "networkFirewallPoliciesWebApplication": "waf", - "networkFirewallPoliciesRuleGroups": "wafrg", - "networkFrontDoors": "fd-", - "networkFrontdoorWebApplicationFirewallPolicies": "fdfp-", - "networkLoadBalancersExternal": "lbe-", - "networkLoadBalancersInternal": "lbi-", - "networkLoadBalancersInboundNatRules": "rule-", - "networkLocalNetworkGateways": "lgw-", - "networkNatGateways": "ng-", - "networkNetworkInterfaces": "nic-", - "networkNetworkSecurityGroups": "nsg-", - "networkNetworkSecurityGroupsSecurityRules": "nsgsr-", - "networkNetworkWatchers": "nw-", - "networkPrivateDnsZones": "pdnsz-", - "networkPrivateLinkServices": "pl-", - "networkPublicIPAddresses": "pip-", - "networkPublicIPPrefixes": "ippre-", - "networkRouteFilters": "rf-", - "networkRouteTables": "rt-", - "networkRouteTablesRoutes": "udr-", - "networkTrafficManagerProfiles": "traf-", - "networkVirtualNetworkGateways": "vgw-", - "networkVirtualNetworks": "vnet-", - "networkVirtualNetworksSubnets": "snet-", - "networkVirtualNetworksVirtualNetworkPeerings": "peer-", - "networkVirtualWans": "vwan-", - "networkVpnGateways": "vpng-", - "networkVpnGatewaysVpnConnections": "vcn-", - "networkVpnGatewaysVpnSites": "vst-", - "notificationHubsNamespaces": "ntfns-", - "notificationHubsNamespacesNotificationHubs": "ntf-", - "operationalInsightsWorkspaces": "log-", - "portalDashboards": "dash-", - "powerBIDedicatedCapacities": "pbi-", - "purviewAccounts": "pview-", - "recoveryServicesVaults": "rsv-", - "resourcesResourceGroups": "rg-", - "searchSearchServices": "srch-", - "serviceBusNamespaces": "sb-", - "serviceBusNamespacesQueues": "sbq-", - "serviceBusNamespacesTopics": "sbt-", - "serviceEndPointPolicies": "se-", - "serviceFabricClusters": "sf-", - "signalRServiceSignalR": "sigr", - "sqlManagedInstances": "sqlmi-", - "sqlServers": "sql-", - "sqlServersDataWarehouse": "sqldw-", - "sqlServersDatabases": "sqldb-", - "sqlServersDatabasesStretch": "sqlstrdb-", - "storageStorageAccounts": "st", - "storageStorageAccountsVm": "stvm", - "storSimpleManagers": "ssimp", - "streamAnalyticsCluster": "asa-", - "synapseWorkspaces": "syn", - "synapseWorkspacesAnalyticsWorkspaces": "synw", - "synapseWorkspacesSqlPoolsDedicated": "syndp", - "synapseWorkspacesSqlPoolsSpark": "synsp", - "timeSeriesInsightsEnvironments": "tsi-", - "webServerFarms": "plan-", - "webSitesAppService": "app-", - "webSitesAppServiceEnvironment": "ase-", - "webSitesFunctions": "func-", - "webStaticSites": "stapp-" -} +{ + "aiFoundryAccounts": "aif", + "analysisServicesServers": "as", + "apiManagementService": "apim-", + "appConfigurationStores": "appcs-", + "appManagedEnvironments": "cae-", + "appContainerApps": "ca-", + "authorizationPolicyDefinitions": "policy-", + "automationAutomationAccounts": "aa-", + "blueprintBlueprints": "bp-", + "blueprintBlueprintsArtifacts": "bpa-", + "cacheRedis": "redis-", + "cdnProfiles": "cdnp-", + "cdnProfilesEndpoints": "cdne-", + "cognitiveServicesAccounts": "cog-", + "cognitiveServicesFormRecognizer": "cog-fr-", + "cognitiveServicesTextAnalytics": "cog-ta-", + "computeAvailabilitySets": "avail-", + "computeCloudServices": "cld-", + "computeDiskEncryptionSets": "des", + "computeDisks": "disk", + "computeDisksOs": "osdisk", + "computeGalleries": "gal", + "computeSnapshots": "snap-", + "computeVirtualMachines": "vm", + "computeVirtualMachineScaleSets": "vmss-", + "containerInstanceContainerGroups": "ci", + "containerRegistryRegistries": "cr", + "containerServiceManagedClusters": "aks-", + "databricksWorkspaces": "dbw-", + "dataFactoryFactories": "adf-", + "dataLakeAnalyticsAccounts": "dla", + "dataLakeStoreAccounts": "dls", + "dataMigrationServices": "dms-", + "dBforMySQLServers": "mysql-", + "dBforPostgreSQLServers": "psql-", + "devicesIotHubs": "iot-", + "devicesProvisioningServices": "provs-", + "devicesProvisioningServicesCertificates": "pcert-", + "documentDBDatabaseAccounts": "cosmos-", + "documentDBMongoDatabaseAccounts": "cosmon-", + "eventGridDomains": "evgd-", + "eventGridDomainsTopics": "evgt-", + "eventGridEventSubscriptions": "evgs-", + "eventHubNamespaces": "evhns-", + "eventHubNamespacesEventHubs": "evh-", + "hdInsightClustersHadoop": "hadoop-", + "hdInsightClustersHbase": "hbase-", + "hdInsightClustersKafka": "kafka-", + "hdInsightClustersMl": "mls-", + "hdInsightClustersSpark": "spark-", + "hdInsightClustersStorm": "storm-", + "hybridComputeMachines": "arcs-", + "insightsActionGroups": "ag-", + "insightsComponents": "appi-", + "keyVaultVaults": "kv-", + "kubernetesConnectedClusters": "arck", + "kustoClusters": "dec", + "kustoClustersDatabases": "dedb", + "logicIntegrationAccounts": "ia-", + "logicWorkflows": "logic-", + "machineLearningServicesWorkspaces": "mlw-", + "managedIdentityUserAssignedIdentities": "id-", + "managementManagementGroups": "mg-", + "migrateAssessmentProjects": "migr-", + "networkApplicationGateways": "agw-", + "networkApplicationSecurityGroups": "asg-", + "networkAzureFirewalls": "afw-", + "networkBastionHosts": "bas-", + "networkConnections": "con-", + "networkDnsZones": "dnsz-", + "networkExpressRouteCircuits": "erc-", + "networkFirewallPolicies": "afwp-", + "networkFirewallPoliciesWebApplication": "waf", + "networkFirewallPoliciesRuleGroups": "wafrg", + "networkFrontDoors": "fd-", + "networkFrontdoorWebApplicationFirewallPolicies": "fdfp-", + "networkLoadBalancersExternal": "lbe-", + "networkLoadBalancersInternal": "lbi-", + "networkLoadBalancersInboundNatRules": "rule-", + "networkLocalNetworkGateways": "lgw-", + "networkNatGateways": "ng-", + "networkNetworkInterfaces": "nic-", + "networkNetworkSecurityGroups": "nsg-", + "networkNetworkSecurityGroupsSecurityRules": "nsgsr-", + "networkNetworkWatchers": "nw-", + "networkPrivateDnsZones": "pdnsz-", + "networkPrivateLinkServices": "pl-", + "networkPublicIPAddresses": "pip-", + "networkPublicIPPrefixes": "ippre-", + "networkRouteFilters": "rf-", + "networkRouteTables": "rt-", + "networkRouteTablesRoutes": "udr-", + "networkTrafficManagerProfiles": "traf-", + "networkVirtualNetworkGateways": "vgw-", + "networkVirtualNetworks": "vnet-", + "networkVirtualNetworksSubnets": "snet-", + "networkVirtualNetworksVirtualNetworkPeerings": "peer-", + "networkVirtualWans": "vwan-", + "networkVpnGateways": "vpng-", + "networkVpnGatewaysVpnConnections": "vcn-", + "networkVpnGatewaysVpnSites": "vst-", + "notificationHubsNamespaces": "ntfns-", + "notificationHubsNamespacesNotificationHubs": "ntf-", + "operationalInsightsWorkspaces": "log-", + "portalDashboards": "dash-", + "powerBIDedicatedCapacities": "pbi-", + "purviewAccounts": "pview-", + "recoveryServicesVaults": "rsv-", + "resourcesResourceGroups": "rg-", + "searchSearchServices": "srch-", + "serviceBusNamespaces": "sb-", + "serviceBusNamespacesQueues": "sbq-", + "serviceBusNamespacesTopics": "sbt-", + "serviceEndPointPolicies": "se-", + "serviceFabricClusters": "sf-", + "signalRServiceSignalR": "sigr", + "sqlManagedInstances": "sqlmi-", + "sqlServers": "sql-", + "sqlServersDataWarehouse": "sqldw-", + "sqlServersDatabases": "sqldb-", + "sqlServersDatabasesStretch": "sqlstrdb-", + "storageStorageAccounts": "st", + "storageStorageAccountsVm": "stvm", + "storSimpleManagers": "ssimp", + "streamAnalyticsCluster": "asa-", + "synapseWorkspaces": "syn", + "synapseWorkspacesAnalyticsWorkspaces": "synw", + "synapseWorkspacesSqlPoolsDedicated": "syndp", + "synapseWorkspacesSqlPoolsSpark": "synsp", + "timeSeriesInsightsEnvironments": "tsi-", + "webServerFarms": "plan-", + "webSitesAppService": "app-", + "webSitesAppServiceEnvironment": "ase-", + "webSitesFunctions": "func-", + "webStaticSites": "stapp-" +} diff --git a/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/ai/acr-role-assignment.bicep b/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/ai/acr-role-assignment.bicep new file mode 100644 index 0000000..3e0c2b2 --- /dev/null +++ b/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/ai/acr-role-assignment.bicep @@ -0,0 +1,27 @@ +targetScope = 'resourceGroup' + +@description('Name of the existing container registry') +param acrName string + +@description('Principal ID to grant AcrPull role') +param principalId string + +@description('Full resource ID of the ACR (for generating unique GUID)') +param acrResourceId string + +// Reference the existing ACR in this resource group +resource acr 'Microsoft.ContainerRegistry/registries@2023-07-01' existing = { + name: acrName +} + +// Grant AcrPull role to the AI project's managed identity +resource acrPullRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: acr + name: guid(acrResourceId, principalId, '7f951dda-4ed3-4680-a7ca-43fe172d538d') + properties: { + principalId: principalId + principalType: 'ServicePrincipal' + // AcrPull role + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + } +} diff --git a/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/ai/ai-project.bicep b/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/ai/ai-project.bicep new file mode 100644 index 0000000..662b53c --- /dev/null +++ b/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/ai/ai-project.bicep @@ -0,0 +1,413 @@ +targetScope = 'resourceGroup' + +@description('Tags that will be applied to all resources') +param tags object = {} + +@description('Main location for the resources') +param location string + +var resourceToken = uniqueString(subscription().id, resourceGroup().id, location) + +@description('Name of the project') +param aiFoundryProjectName string + +param deployments deploymentsType + +@description('Id of the user or app to assign application roles') +param principalId string + +@description('Principal type of user or app') +param principalType string + +@description('Optional. Name of an existing AI Services account in the current resource group. If not provided, a new one will be created.') +param existingAiAccountName string = '' + +@description('List of connections to provision') +param connections array = [] + +@secure() +@description('Map of connection name to credentials object. Kept as @secure to prevent secrets from appearing in deployment logs. Example: { "my-conn": { "key": "secret" } }') +param connectionCredentials object = {} + +@description('Also provision dependent resources and connect to the project') +param additionalDependentResources dependentResourcesType + +@description('Enable monitoring via appinsights and log analytics') +param enableMonitoring bool = true + +@description('Enable hosted agent deployment') +param enableHostedAgents bool = false + +@description('Enable the capability host for agent conversations. When false and hosted agents are enabled, the capability host is not created (v2 hosted agents handle storage automatically).') +param enableCapabilityHost bool = true + +@description('Optional. Existing container registry resource ID. If provided, a connection will be created to this ACR instead of creating a new one.') +param existingContainerRegistryResourceId string = '' + +@description('Optional. Existing container registry login server (e.g., myregistry.azurecr.io). Required if existingContainerRegistryResourceId is provided.') +param existingContainerRegistryEndpoint string = '' + +@description('Optional. Name of an existing ACR connection on the Foundry project. If provided, no new ACR or connection will be created.') +param existingAcrConnectionName string = '' + +@description('Optional. Existing Application Insights connection string. If provided, a connection will be created but no new App Insights resource.') +param existingApplicationInsightsConnectionString string = '' + +@description('Optional. Existing Application Insights resource ID. Used for connection metadata when providing an existing App Insights.') +param existingApplicationInsightsResourceId string = '' + +@description('Optional. Name of an existing Application Insights connection on the Foundry project. If provided, no new App Insights or connection will be created.') +param existingAppInsightsConnectionName string = '' + +// Load abbreviations +var abbrs = loadJsonContent('../../abbreviations.json') + +// Determine which resources to create based on connections +var hasStorageConnection = length(filter(additionalDependentResources, conn => conn.resource == 'storage')) > 0 +var hasAcrConnection = length(filter(additionalDependentResources, conn => conn.resource == 'registry')) > 0 +var hasExistingAcr = !empty(existingContainerRegistryResourceId) +var hasExistingAcrConnection = !empty(existingAcrConnectionName) +var hasExistingAppInsightsConnection = !empty(existingAppInsightsConnectionName) +var hasExistingAppInsightsConnectionString = !empty(existingApplicationInsightsConnectionString) +// Only create new App Insights resources if monitoring enabled and no existing connection/connection string +var shouldCreateAppInsights = enableMonitoring && !hasExistingAppInsightsConnection && !hasExistingAppInsightsConnectionString +var hasSearchConnection = length(filter(additionalDependentResources, conn => conn.resource == 'azure_ai_search')) > 0 +var hasBingConnection = length(filter(additionalDependentResources, conn => conn.resource == 'bing_grounding')) > 0 +var hasBingCustomConnection = length(filter(additionalDependentResources, conn => conn.resource == 'bing_custom_grounding')) > 0 + +// Extract connection names from ai.yaml for each resource type +var storageConnectionName = hasStorageConnection ? filter(additionalDependentResources, conn => conn.resource == 'storage')[0].connectionName : '' +var acrConnectionName = hasAcrConnection ? filter(additionalDependentResources, conn => conn.resource == 'registry')[0].connectionName : '' +var searchConnectionName = hasSearchConnection ? filter(additionalDependentResources, conn => conn.resource == 'azure_ai_search')[0].connectionName : '' +var bingConnectionName = hasBingConnection ? filter(additionalDependentResources, conn => conn.resource == 'bing_grounding')[0].connectionName : '' +var bingCustomConnectionName = hasBingCustomConnection ? filter(additionalDependentResources, conn => conn.resource == 'bing_custom_grounding')[0].connectionName : '' + +// Enable monitoring via Log Analytics and Application Insights +module logAnalytics '../monitor/loganalytics.bicep' = if (shouldCreateAppInsights) { + name: 'logAnalytics' + params: { + location: location + tags: tags + name: 'logs-${resourceToken}' + } +} + +module applicationInsights '../monitor/applicationinsights.bicep' = if (shouldCreateAppInsights) { + name: 'applicationInsights' + params: { + location: location + tags: tags + name: 'appi-${resourceToken}' + logAnalyticsWorkspaceId: logAnalytics.outputs.id + projectMIPrincipalId: aiAccount::project.identity.principalId + } +} + +// Always create a new AI Account for now (simplified approach) +// TODO: Add support for existing accounts in a future version +resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' = { + name: !empty(existingAiAccountName) ? existingAiAccountName : 'ai-account-${resourceToken}' + location: location + tags: tags + sku: { + name: 'S0' + } + kind: 'AIServices' + identity: { + type: 'SystemAssigned' + } + properties: { + allowProjectManagement: true + customSubDomainName: !empty(existingAiAccountName) ? existingAiAccountName : 'ai-account-${resourceToken}' + networkAcls: { + defaultAction: 'Allow' + virtualNetworkRules: [] + ipRules: [] + } + publicNetworkAccess: 'Enabled' + disableLocalAuth: true + } + + @batchSize(1) + resource seqDeployments 'deployments' = [ + for dep in (deployments??[]): { + name: dep.name + properties: { + model: dep.model + } + sku: dep.sku + } + ] + + resource project 'projects' = { + name: aiFoundryProjectName + location: location + identity: { + type: 'SystemAssigned' + } + properties: { + description: '${aiFoundryProjectName} Project' + displayName: '${aiFoundryProjectName}Project' + } + dependsOn: [ + seqDeployments + ] + } + + resource aiFoundryAccountCapabilityHost 'capabilityHosts@2025-10-01-preview' = if (enableHostedAgents && enableCapabilityHost) { + name: 'agents' + properties: { + capabilityHostKind: 'Agents' + // IMPORTANT: this is required to enable hosted agents deployment + // if no BYO Net is provided + enablePublicHostingEnvironment: true + } + } +} + + +// Create connection towards appinsights: +// - when we create a new App Insights resource, OR +// - when the user provided an existing App Insights connection string + resource ID but no existing connection name +// Both cases are merged into a single resource to avoid duplicate ARM resource definitions (which fail deployment). +var shouldCreateExistingAppInsightsConnection = enableMonitoring && hasExistingAppInsightsConnectionString && !hasExistingAppInsightsConnection && !empty(existingApplicationInsightsResourceId) +var shouldCreateAppInsightsConnection = shouldCreateAppInsights || shouldCreateExistingAppInsightsConnection + +resource appInsightConnection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = if (shouldCreateAppInsightsConnection) { + parent: aiAccount::project + name: 'appi-${resourceToken}' + properties: { + category: 'AppInsights' + target: shouldCreateAppInsights ? applicationInsights.outputs.id : existingApplicationInsightsResourceId + authType: 'ApiKey' + isSharedToAll: true + credentials: { + key: shouldCreateAppInsights ? applicationInsights.outputs.connectionString : existingApplicationInsightsConnectionString + } + metadata: { + ApiType: 'Azure' + ResourceId: shouldCreateAppInsights ? applicationInsights.outputs.id : existingApplicationInsightsResourceId + } + } +} + +// Create additional connections from ai.yaml configuration +module aiConnections './connection.bicep' = [for (connection, index) in connections: { + name: 'connection-${connection.name}' + params: { + aiServicesAccountName: aiAccount.name + aiProjectName: aiAccount::project.name + connectionConfig: connection + credentials: connectionCredentials[?connection.name] ?? {} + } +}] + +// Azure AI User for the developer, scoped to the Foundry Project. +// Project scope is sufficient for creating/running agents and calling models via the project endpoint. +resource localUserAzureAIUserRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: aiAccount::project + name: guid(subscription().id, resourceGroup().id, principalId, '53ca6127-db72-4b80-b1b0-d745d6d5456d') + properties: { + principalId: principalId + principalType: principalType + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', '53ca6127-db72-4b80-b1b0-d745d6d5456d') + } +} + + +// All connections are now created directly within their respective resource modules +// using the centralized ./connection.bicep module + +// Storage module - deploy if storage connection is defined in ai.yaml +module storage '../storage/storage.bicep' = if (hasStorageConnection) { + name: 'storage' + params: { + location: location + tags: tags + resourceName: 'st${resourceToken}' + connectionName: storageConnectionName + principalId: principalId + principalType: principalType + aiServicesAccountName: aiAccount.name + aiProjectName: aiAccount::project.name + } +} + +// Azure Container Registry module - deploy if ACR connection is defined in ai.yaml +module acr '../host/acr.bicep' = if (hasAcrConnection) { + name: 'acr' + params: { + location: location + tags: tags + resourceName: '${abbrs.containerRegistryRegistries}${resourceToken}' + connectionName: acrConnectionName + principalId: principalId + principalType: principalType + aiServicesAccountName: aiAccount.name + aiProjectName: aiAccount::project.name + } +} + +// Connection for existing ACR - create if user provided an existing ACR resource ID but no existing connection +module existingAcrConnection './connection.bicep' = if (hasExistingAcr && !hasExistingAcrConnection) { + name: 'existing-acr-connection' + params: { + aiServicesAccountName: aiAccount.name + aiProjectName: aiAccount::project.name + connectionConfig: { + name: 'acr-${resourceToken}' + category: 'ContainerRegistry' + target: existingContainerRegistryEndpoint + authType: 'ManagedIdentity' + isSharedToAll: true + metadata: { + ResourceId: existingContainerRegistryResourceId + } + } + credentials: { + clientId: aiAccount::project.identity.principalId + resourceId: existingContainerRegistryResourceId + } + } +} + +// Extract resource group name from the existing ACR resource ID +// Resource ID format: /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.ContainerRegistry/registries/{name} +var existingAcrResourceGroup = hasExistingAcr ? split(existingContainerRegistryResourceId, '/')[4] : '' +var existingAcrName = hasExistingAcr ? last(split(existingContainerRegistryResourceId, '/')) : '' + +// Grant AcrPull role to the AI project's managed identity on the existing ACR +// This allows the hosted agents to pull images from the user-provided registry +// Note: User must have permission to assign roles on the existing ACR (Owner or User Access Administrator) +// Using a module allows scoping to a different resource group if the ACR isn't in the same RG +// Skip if connection already exists (role assignment should already be in place) +module existingAcrRoleAssignment './acr-role-assignment.bicep' = if (hasExistingAcr && !hasExistingAcrConnection) { + name: 'existing-acr-role-assignment' + scope: resourceGroup(existingAcrResourceGroup) + params: { + acrName: existingAcrName + acrResourceId: existingContainerRegistryResourceId + principalId: aiAccount::project.identity.principalId + } +} + +// Bing Search grounding module - deploy if Bing connection is defined in ai.yaml or parameter is enabled +module bingGrounding '../search/bing_grounding.bicep' = if (hasBingConnection) { + name: 'bing-grounding' + params: { + tags: tags + resourceName: 'bing-${resourceToken}' + connectionName: bingConnectionName + aiServicesAccountName: aiAccount.name + aiProjectName: aiAccount::project.name + } +} + +// Bing Custom Search grounding module - deploy if custom Bing connection is defined in ai.yaml or parameter is enabled +module bingCustomGrounding '../search/bing_custom_grounding.bicep' = if (hasBingCustomConnection) { + name: 'bing-custom-grounding' + params: { + tags: tags + resourceName: 'bingcustom-${resourceToken}' + connectionName: bingCustomConnectionName + aiServicesAccountName: aiAccount.name + aiProjectName: aiAccount::project.name + } +} + +// Azure AI Search module - deploy if search connection is defined in ai.yaml +module azureAiSearch '../search/azure_ai_search.bicep' = if (hasSearchConnection) { + name: 'azure-ai-search' + params: { + tags: tags + resourceName: 'search-${resourceToken}' + connectionName: searchConnectionName + storageAccountResourceId: hasStorageConnection ? storage!.outputs.storageAccountId : '' + containerName: 'knowledge' + aiServicesAccountName: aiAccount.name + aiProjectName: aiAccount::project.name + principalId: principalId + principalType: principalType + location: location + } +} + +// Outputs +output AZURE_AI_PROJECT_ENDPOINT string = aiAccount::project.properties.endpoints['AI Foundry API'] +output AZURE_OPENAI_ENDPOINT string = aiAccount.properties.endpoints['OpenAI Language Model Instance API'] +output aiServicesEndpoint string = aiAccount.properties.endpoint +output accountId string = aiAccount.id +output projectId string = aiAccount::project.id +output aiServicesAccountName string = aiAccount.name +output aiServicesProjectName string = aiAccount::project.name +output aiServicesPrincipalId string = aiAccount.identity.principalId +output projectName string = aiAccount::project.name +output APPLICATIONINSIGHTS_CONNECTION_STRING string = shouldCreateAppInsights ? applicationInsights.outputs.connectionString : (hasExistingAppInsightsConnectionString ? existingApplicationInsightsConnectionString : '') +output APPLICATIONINSIGHTS_RESOURCE_ID string = shouldCreateAppInsights ? applicationInsights.outputs.id : (hasExistingAppInsightsConnectionString ? existingApplicationInsightsResourceId : '') + +// Connection outputs from the connections array +output connectionIds array = [for (connection, index) in (connections ?? []): { + name: aiConnections[index].outputs.connectionName + id: aiConnections[index].outputs.connectionId +}] + +// Grouped dependent resources outputs +output dependentResources object = { + registry: { + name: hasAcrConnection ? acr!.outputs.containerRegistryName : '' + loginServer: hasAcrConnection ? acr!.outputs.containerRegistryLoginServer : ((hasExistingAcr || hasExistingAcrConnection) ? existingContainerRegistryEndpoint : '') + connectionName: hasAcrConnection ? acr!.outputs.containerRegistryConnectionName : (hasExistingAcrConnection ? existingAcrConnectionName : (hasExistingAcr ? 'acr-${resourceToken}' : '')) + } + bing_grounding: { + name: (hasBingConnection) ? bingGrounding!.outputs.bingGroundingName : '' + connectionName: (hasBingConnection) ? bingGrounding!.outputs.bingGroundingConnectionName : '' + connectionId: (hasBingConnection) ? bingGrounding!.outputs.bingGroundingConnectionId : '' + } + bing_custom_grounding: { + name: (hasBingCustomConnection) ? bingCustomGrounding!.outputs.bingCustomGroundingName : '' + connectionName: (hasBingCustomConnection) ? bingCustomGrounding!.outputs.bingCustomGroundingConnectionName : '' + connectionId: (hasBingCustomConnection) ? bingCustomGrounding!.outputs.bingCustomGroundingConnectionId : '' + } + search: { + serviceName: hasSearchConnection ? azureAiSearch!.outputs.searchServiceName : '' + connectionName: hasSearchConnection ? azureAiSearch!.outputs.searchConnectionName : '' + } + storage: { + accountName: hasStorageConnection ? storage!.outputs.storageAccountName : '' + connectionName: hasStorageConnection ? storage!.outputs.storageConnectionName : '' + } +} + +type deploymentsType = { + @description('Specify the name of cognitive service account deployment.') + name: string + + @description('Required. Properties of Cognitive Services account deployment model.') + model: { + @description('Required. The name of Cognitive Services account deployment model.') + name: string + + @description('Required. The format of Cognitive Services account deployment model.') + format: string + + @description('Required. The version of Cognitive Services account deployment model.') + version: string + } + + @description('The resource model definition representing SKU.') + sku: { + @description('Required. The name of the resource model definition representing SKU.') + name: string + + @description('The capacity of the resource model definition representing SKU.') + capacity: int + } +}[]? + +type dependentResourcesType = { + @description('The type of dependent resource to create') + resource: 'storage' | 'registry' | 'azure_ai_search' | 'bing_grounding' | 'bing_custom_grounding' + + @description('The connection name for this resource') + connectionName: string +}[] diff --git a/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/ai/connection.bicep b/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/ai/connection.bicep new file mode 100644 index 0000000..a087266 --- /dev/null +++ b/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/ai/connection.bicep @@ -0,0 +1,112 @@ +targetScope = 'resourceGroup' + +@description('AI Services account name') +param aiServicesAccountName string + +@description('AI project name') +param aiProjectName string + +// Connection configuration type definition +type ConnectionConfig = { + @description('Name of the connection') + name: string + + @description('Category of the connection (e.g., ContainerRegistry, AzureStorageAccount, CognitiveSearch, AzureOpenAI)') + category: string + + @description('Target endpoint or URL for the connection') + target: string + + @description('Authentication type') + authType: 'AAD' | 'AccessKey' | 'AccountKey' | 'AgenticIdentity' | 'ApiKey' | 'CustomKeys' | 'ManagedIdentity' | 'None' | 'OAuth2' | 'PAT' | 'SAS' | 'ServicePrincipal' | 'UsernamePassword' | 'UserEntraToken' | 'ProjectManagedIdentity' + + @description('Whether the connection is shared to all users (optional, defaults to true)') + isSharedToAll: bool? + + @description('Additional metadata for the connection (optional)') + metadata: object? + + @description('Error message if the connection fails (optional)') + error: string? + + @description('Expiry time for the connection (optional)') + expiryTime: string? + + @description('Private endpoint requirement: Required, NotRequired, or NotApplicable (optional)') + peRequirement: ('NotApplicable' | 'NotRequired' | 'Required')? + + @description('Private endpoint status: Active, Inactive, or NotApplicable (optional)') + peStatus: ('Active' | 'Inactive' | 'NotApplicable')? + + @description('List of users to share the connection with (optional, alternative to isSharedToAll)') + sharedUserList: string[]? + + @description('Whether to use workspace managed identity (optional)') + useWorkspaceManagedIdentity: bool? + + @description('OAuth2 authorization endpoint URL (optional, OAuth2 authType only)') + authorizationUrl: string? + + @description('OAuth2 token endpoint URL (optional, OAuth2 authType only)') + tokenUrl: string? + + @description('OAuth2 refresh token endpoint URL (optional, OAuth2 authType only)') + refreshUrl: string? + + @description('OAuth2 scopes to request (optional, OAuth2 authType only)') + scopes: string[]? + + @description('Token audience for UserEntraToken / AgenticIdentity auth types (optional)') + audience: string? + + @description('Managed connector name for OAuth2 managed connectors (optional)') + connectorName: string? +} + +@description('Connection configuration') +param connectionConfig ConnectionConfig + +@secure() +@description('Credentials for the connection. Kept as a separate @secure parameter to prevent secrets from appearing in deployment logs. Shape depends on authType — e.g. { key: "..." } for ApiKey, { clientId: "...", clientSecret: "..." } for OAuth2/ServicePrincipal.') +param credentials object = {} + + +// Get reference to the AI Services account and project +resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + name: aiServicesAccountName + + resource project 'projects' existing = { + name: aiProjectName + } +} + +// Create the connection +resource connection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = { + parent: aiAccount::project + name: connectionConfig.name + properties: { + category: connectionConfig.category + target: connectionConfig.target + authType: connectionConfig.authType + isSharedToAll: connectionConfig.?isSharedToAll ?? true + credentials: !empty(credentials) ? credentials : null + metadata: connectionConfig.?metadata + // Only include if they appear in the connectionConfig + ...connectionConfig.?error != null ? { error: connectionConfig.?error } : {} + ...connectionConfig.?expiryTime != null ? { expiryTime: connectionConfig.?expiryTime } : {} + ...connectionConfig.?peRequirement != null ? { peRequirement: connectionConfig.?peRequirement } : {} + ...connectionConfig.?peStatus != null ? { peStatus: connectionConfig.?peStatus } : {} + ...connectionConfig.?sharedUserList != null ? { sharedUserList: connectionConfig.?sharedUserList } : {} + ...connectionConfig.?useWorkspaceManagedIdentity != null ? { useWorkspaceManagedIdentity: connectionConfig.?useWorkspaceManagedIdentity } : {} + ...connectionConfig.?authorizationUrl != null ? { authorizationUrl: connectionConfig.?authorizationUrl } : {} + ...connectionConfig.?tokenUrl != null ? { tokenUrl: connectionConfig.?tokenUrl } : {} + ...connectionConfig.?refreshUrl != null ? { refreshUrl: connectionConfig.?refreshUrl } : {} + ...connectionConfig.?scopes != null ? { scopes: connectionConfig.?scopes } : {} + ...connectionConfig.?audience != null ? { audience: connectionConfig.?audience } : {} + ...connectionConfig.?connectorName != null ? { connectorName: connectionConfig.?connectorName } : {} + } +} + +// Outputs +output connectionName string = connection.name +output connectionId string = connection.id diff --git a/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/ai/existing-ai-project.bicep b/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/ai/existing-ai-project.bicep new file mode 100644 index 0000000..4f057b0 --- /dev/null +++ b/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/ai/existing-ai-project.bicep @@ -0,0 +1,70 @@ +targetScope = 'resourceGroup' + +@description('Name of the existing AI Services account') +param aiServicesAccountName string + +@description('Name of the existing AI Foundry project') +param aiFoundryProjectName string + +@description('Existing ACR connection name (already set in the environment)') +param existingAcrConnectionName string = '' + +@description('Existing container registry endpoint (already set in the environment)') +param existingContainerRegistryEndpoint string = '' + +@description('Existing Application Insights connection string (already set in the environment)') +param existingApplicationInsightsConnectionString string = '' + +@description('Existing Application Insights resource ID (already set in the environment)') +param existingApplicationInsightsResourceId string = '' + +// Reference the existing account and project — read-only, no modifications +resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = { + name: aiServicesAccountName + + resource project 'projects' existing = { + name: aiFoundryProjectName + } +} + +// Outputs — same shape as ai-project.bicep so main.bicep can use either interchangeably +output AZURE_AI_PROJECT_ENDPOINT string = aiAccount::project.properties.endpoints['AI Foundry API'] +output AZURE_OPENAI_ENDPOINT string = aiAccount.properties.endpoints['OpenAI Language Model Instance API'] +output aiServicesEndpoint string = aiAccount.properties.endpoint +output accountId string = aiAccount.id +output projectId string = aiAccount::project.id +output aiServicesAccountName string = aiAccount.name +output aiServicesProjectName string = aiAccount::project.name +output aiServicesPrincipalId string = aiAccount.identity.principalId +output projectName string = aiAccount::project.name +output APPLICATIONINSIGHTS_CONNECTION_STRING string = existingApplicationInsightsConnectionString +output APPLICATIONINSIGHTS_RESOURCE_ID string = existingApplicationInsightsResourceId + +// Empty connection outputs — these are already set in the azd environment from init +output connectionIds array = [] + +output dependentResources object = { + registry: { + name: '' + loginServer: existingContainerRegistryEndpoint + connectionName: existingAcrConnectionName + } + bing_grounding: { + name: '' + connectionName: '' + connectionId: '' + } + bing_custom_grounding: { + name: '' + connectionName: '' + connectionId: '' + } + search: { + serviceName: '' + connectionName: '' + } + storage: { + accountName: '' + connectionName: '' + } +} diff --git a/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/host/acr.bicep b/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/host/acr.bicep new file mode 100644 index 0000000..f1893d8 --- /dev/null +++ b/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/host/acr.bicep @@ -0,0 +1,88 @@ +targetScope = 'resourceGroup' + +@description('The location used for all deployed resources') +param location string = resourceGroup().location + +@description('Tags that will be applied to all resources') +param tags object = {} + +@description('Resource name for the container registry') +param resourceName string + +@description('Id of the user or app to assign application roles') +param principalId string + +@description('Principal type of user or app') +param principalType string + +@description('AI Services account name for the project parent') +param aiServicesAccountName string = '' + +@description('AI project name for creating the connection') +param aiProjectName string = '' + +@description('Name for the AI Foundry ACR connection') +param connectionName string + +// Get reference to the AI Services account and project to access their managed identities +resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: aiServicesAccountName + + resource aiProject 'projects' existing = { + name: aiProjectName + } +} + +// Create the Container Registry +module containerRegistry 'br/public:avm/res/container-registry/registry:0.1.1' = { + name: 'registry' + params: { + name: resourceName + location: location + tags: tags + publicNetworkAccess: 'Enabled' + roleAssignments:[ + { + principalId: principalId + principalType: principalType + // Container Registry Tasks Contributor — build images with ACR tasks and push container images + roleDefinitionIdOrName: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'fb382eab-e894-4461-af04-94435c366c3f') + } + // TODO SEPARATELY + { + // the foundry project itself can pull from the ACR + principalId: aiAccount::aiProject.identity.principalId + principalType: 'ServicePrincipal' + roleDefinitionIdOrName: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + } + ] + } +} + +// Create the ACR connection using the centralized connection module +module acrConnection '../ai/connection.bicep' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: 'acr-connection-creation' + params: { + aiServicesAccountName: aiServicesAccountName + aiProjectName: aiProjectName + connectionConfig: { + name: connectionName + category: 'ContainerRegistry' + target: containerRegistry.outputs.loginServer + authType: 'ManagedIdentity' + isSharedToAll: true + metadata: { + ResourceId: containerRegistry.outputs.resourceId + } + } + credentials: { + clientId: aiAccount::aiProject.identity.principalId + resourceId: containerRegistry.outputs.resourceId + } + } +} + +output containerRegistryName string = containerRegistry.outputs.name +output containerRegistryLoginServer string = containerRegistry.outputs.loginServer +output containerRegistryResourceId string = containerRegistry.outputs.resourceId +output containerRegistryConnectionName string = acrConnection.outputs.connectionName diff --git a/08-agents/08-09/README (14).md b/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/monitor/applicationinsights-dashboard.bicep similarity index 97% rename from 08-agents/08-09/README (14).md rename to 08-agents/08-10-hosted-copilot-sdk-agent/infra/core/monitor/applicationinsights-dashboard.bicep index f3e0952..d082e66 100644 --- a/08-agents/08-09/README (14).md +++ b/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/monitor/applicationinsights-dashboard.bicep @@ -1,1236 +1,1236 @@ -metadata description = 'Creates a dashboard for an Application Insights instance.' -param name string -param applicationInsightsName string -param location string = resourceGroup().location -param tags object = {} - -// 2020-09-01-preview because that is the latest valid version -resource applicationInsightsDashboard 'Microsoft.Portal/dashboards@2020-09-01-preview' = { - name: name - location: location - tags: tags - properties: { - lenses: [ - { - order: 0 - parts: [ - { - position: { - x: 0 - y: 0 - colSpan: 2 - rowSpan: 1 - } - metadata: { - inputs: [ - { - name: 'id' - value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - { - name: 'Version' - value: '1.0' - } - ] - #disable-next-line BCP036 - type: 'Extension/AppInsightsExtension/PartType/AspNetOverviewPinnedPart' - asset: { - idInputName: 'id' - type: 'ApplicationInsights' - } - defaultMenuItemId: 'overview' - } - } - { - position: { - x: 2 - y: 0 - colSpan: 1 - rowSpan: 1 - } - metadata: { - inputs: [ - { - name: 'ComponentId' - value: { - Name: applicationInsights.name - SubscriptionId: subscription().subscriptionId - ResourceGroup: resourceGroup().name - } - } - { - name: 'Version' - value: '1.0' - } - ] - #disable-next-line BCP036 - type: 'Extension/AppInsightsExtension/PartType/ProactiveDetectionAsyncPart' - asset: { - idInputName: 'ComponentId' - type: 'ApplicationInsights' - } - defaultMenuItemId: 'ProactiveDetection' - } - } - { - position: { - x: 3 - y: 0 - colSpan: 1 - rowSpan: 1 - } - metadata: { - inputs: [ - { - name: 'ComponentId' - value: { - Name: applicationInsights.name - SubscriptionId: subscription().subscriptionId - ResourceGroup: resourceGroup().name - } - } - { - name: 'ResourceId' - value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - ] - #disable-next-line BCP036 - type: 'Extension/AppInsightsExtension/PartType/QuickPulseButtonSmallPart' - asset: { - idInputName: 'ComponentId' - type: 'ApplicationInsights' - } - } - } - { - position: { - x: 4 - y: 0 - colSpan: 1 - rowSpan: 1 - } - metadata: { - inputs: [ - { - name: 'ComponentId' - value: { - Name: applicationInsights.name - SubscriptionId: subscription().subscriptionId - ResourceGroup: resourceGroup().name - } - } - { - name: 'TimeContext' - value: { - durationMs: 86400000 - endTime: null - createdTime: '2018-05-04T01:20:33.345Z' - isInitialTime: true - grain: 1 - useDashboardTimeRange: false - } - } - { - name: 'Version' - value: '1.0' - } - ] - #disable-next-line BCP036 - type: 'Extension/AppInsightsExtension/PartType/AvailabilityNavButtonPart' - asset: { - idInputName: 'ComponentId' - type: 'ApplicationInsights' - } - } - } - { - position: { - x: 5 - y: 0 - colSpan: 1 - rowSpan: 1 - } - metadata: { - inputs: [ - { - name: 'ComponentId' - value: { - Name: applicationInsights.name - SubscriptionId: subscription().subscriptionId - ResourceGroup: resourceGroup().name - } - } - { - name: 'TimeContext' - value: { - durationMs: 86400000 - endTime: null - createdTime: '2018-05-08T18:47:35.237Z' - isInitialTime: true - grain: 1 - useDashboardTimeRange: false - } - } - { - name: 'ConfigurationId' - value: '78ce933e-e864-4b05-a27b-71fd55a6afad' - } - ] - #disable-next-line BCP036 - type: 'Extension/AppInsightsExtension/PartType/AppMapButtonPart' - asset: { - idInputName: 'ComponentId' - type: 'ApplicationInsights' - } - } - } - { - position: { - x: 0 - y: 1 - colSpan: 3 - rowSpan: 1 - } - metadata: { - inputs: [] - type: 'Extension/HubsExtension/PartType/MarkdownPart' - settings: { - content: { - settings: { - content: '# Usage' - title: '' - subtitle: '' - } - } - } - } - } - { - position: { - x: 3 - y: 1 - colSpan: 1 - rowSpan: 1 - } - metadata: { - inputs: [ - { - name: 'ComponentId' - value: { - Name: applicationInsights.name - SubscriptionId: subscription().subscriptionId - ResourceGroup: resourceGroup().name - } - } - { - name: 'TimeContext' - value: { - durationMs: 86400000 - endTime: null - createdTime: '2018-05-04T01:22:35.782Z' - isInitialTime: true - grain: 1 - useDashboardTimeRange: false - } - } - ] - #disable-next-line BCP036 - type: 'Extension/AppInsightsExtension/PartType/UsageUsersOverviewPart' - asset: { - idInputName: 'ComponentId' - type: 'ApplicationInsights' - } - } - } - { - position: { - x: 4 - y: 1 - colSpan: 3 - rowSpan: 1 - } - metadata: { - inputs: [] - type: 'Extension/HubsExtension/PartType/MarkdownPart' - settings: { - content: { - settings: { - content: '# Reliability' - title: '' - subtitle: '' - } - } - } - } - } - { - position: { - x: 7 - y: 1 - colSpan: 1 - rowSpan: 1 - } - metadata: { - inputs: [ - { - name: 'ResourceId' - value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - { - name: 'DataModel' - value: { - version: '1.0.0' - timeContext: { - durationMs: 86400000 - createdTime: '2018-05-04T23:42:40.072Z' - isInitialTime: false - grain: 1 - useDashboardTimeRange: false - } - } - isOptional: true - } - { - name: 'ConfigurationId' - value: '8a02f7bf-ac0f-40e1-afe9-f0e72cfee77f' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/AppInsightsExtension/PartType/CuratedBladeFailuresPinnedPart' - isAdapter: true - asset: { - idInputName: 'ResourceId' - type: 'ApplicationInsights' - } - defaultMenuItemId: 'failures' - } - } - { - position: { - x: 8 - y: 1 - colSpan: 3 - rowSpan: 1 - } - metadata: { - inputs: [] - type: 'Extension/HubsExtension/PartType/MarkdownPart' - settings: { - content: { - settings: { - content: '# Responsiveness\r\n' - title: '' - subtitle: '' - } - } - } - } - } - { - position: { - x: 11 - y: 1 - colSpan: 1 - rowSpan: 1 - } - metadata: { - inputs: [ - { - name: 'ResourceId' - value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - { - name: 'DataModel' - value: { - version: '1.0.0' - timeContext: { - durationMs: 86400000 - createdTime: '2018-05-04T23:43:37.804Z' - isInitialTime: false - grain: 1 - useDashboardTimeRange: false - } - } - isOptional: true - } - { - name: 'ConfigurationId' - value: '2a8ede4f-2bee-4b9c-aed9-2db0e8a01865' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/AppInsightsExtension/PartType/CuratedBladePerformancePinnedPart' - isAdapter: true - asset: { - idInputName: 'ResourceId' - type: 'ApplicationInsights' - } - defaultMenuItemId: 'performance' - } - } - { - position: { - x: 12 - y: 1 - colSpan: 3 - rowSpan: 1 - } - metadata: { - inputs: [] - type: 'Extension/HubsExtension/PartType/MarkdownPart' - settings: { - content: { - settings: { - content: '# Browser' - title: '' - subtitle: '' - } - } - } - } - } - { - position: { - x: 15 - y: 1 - colSpan: 1 - rowSpan: 1 - } - metadata: { - inputs: [ - { - name: 'ComponentId' - value: { - Name: applicationInsights.name - SubscriptionId: subscription().subscriptionId - ResourceGroup: resourceGroup().name - } - } - { - name: 'MetricsExplorerJsonDefinitionId' - value: 'BrowserPerformanceTimelineMetrics' - } - { - name: 'TimeContext' - value: { - durationMs: 86400000 - createdTime: '2018-05-08T12:16:27.534Z' - isInitialTime: false - grain: 1 - useDashboardTimeRange: false - } - } - { - name: 'CurrentFilter' - value: { - eventTypes: [ - 4 - 1 - 3 - 5 - 2 - 6 - 13 - ] - typeFacets: {} - isPermissive: false - } - } - { - name: 'id' - value: { - Name: applicationInsights.name - SubscriptionId: subscription().subscriptionId - ResourceGroup: resourceGroup().name - } - } - { - name: 'Version' - value: '1.0' - } - ] - #disable-next-line BCP036 - type: 'Extension/AppInsightsExtension/PartType/MetricsExplorerBladePinnedPart' - asset: { - idInputName: 'ComponentId' - type: 'ApplicationInsights' - } - defaultMenuItemId: 'browser' - } - } - { - position: { - x: 0 - y: 2 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'sessions/count' - aggregationType: 5 - namespace: 'microsoft.insights/components/kusto' - metricVisualization: { - displayName: 'Sessions' - color: '#47BDF5' - } - } - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'users/count' - aggregationType: 5 - namespace: 'microsoft.insights/components/kusto' - metricVisualization: { - displayName: 'Users' - color: '#7E58FF' - } - } - ] - title: 'Unique sessions and users' - visualization: { - chartType: 2 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - openBladeOnClick: { - openBlade: true - destinationBlade: { - extensionName: 'HubsExtension' - bladeName: 'ResourceMenuBlade' - parameters: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - menuid: 'segmentationUsers' - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - { - position: { - x: 4 - y: 2 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'requests/failed' - aggregationType: 7 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Failed requests' - color: '#EC008C' - } - } - ] - title: 'Failed requests' - visualization: { - chartType: 3 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - openBladeOnClick: { - openBlade: true - destinationBlade: { - extensionName: 'HubsExtension' - bladeName: 'ResourceMenuBlade' - parameters: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - menuid: 'failures' - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - { - position: { - x: 8 - y: 2 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'requests/duration' - aggregationType: 4 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Server response time' - color: '#00BCF2' - } - } - ] - title: 'Server response time' - visualization: { - chartType: 2 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - openBladeOnClick: { - openBlade: true - destinationBlade: { - extensionName: 'HubsExtension' - bladeName: 'ResourceMenuBlade' - parameters: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - menuid: 'performance' - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - { - position: { - x: 12 - y: 2 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'browserTimings/networkDuration' - aggregationType: 4 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Page load network connect time' - color: '#7E58FF' - } - } - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'browserTimings/processingDuration' - aggregationType: 4 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Client processing time' - color: '#44F1C8' - } - } - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'browserTimings/sendDuration' - aggregationType: 4 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Send request time' - color: '#EB9371' - } - } - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'browserTimings/receiveDuration' - aggregationType: 4 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Receiving response time' - color: '#0672F1' - } - } - ] - title: 'Average page load time breakdown' - visualization: { - chartType: 3 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - { - position: { - x: 0 - y: 5 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'availabilityResults/availabilityPercentage' - aggregationType: 4 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Availability' - color: '#47BDF5' - } - } - ] - title: 'Average availability' - visualization: { - chartType: 3 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - openBladeOnClick: { - openBlade: true - destinationBlade: { - extensionName: 'HubsExtension' - bladeName: 'ResourceMenuBlade' - parameters: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - menuid: 'availability' - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - { - position: { - x: 4 - y: 5 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'exceptions/server' - aggregationType: 7 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Server exceptions' - color: '#47BDF5' - } - } - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'dependencies/failed' - aggregationType: 7 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Dependency failures' - color: '#7E58FF' - } - } - ] - title: 'Server exceptions and Dependency failures' - visualization: { - chartType: 2 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - { - position: { - x: 8 - y: 5 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'performanceCounters/processorCpuPercentage' - aggregationType: 4 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Processor time' - color: '#47BDF5' - } - } - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'performanceCounters/processCpuPercentage' - aggregationType: 4 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Process CPU' - color: '#7E58FF' - } - } - ] - title: 'Average processor and process CPU utilization' - visualization: { - chartType: 2 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - { - position: { - x: 12 - y: 5 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'exceptions/browser' - aggregationType: 7 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Browser exceptions' - color: '#47BDF5' - } - } - ] - title: 'Browser exceptions' - visualization: { - chartType: 2 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - { - position: { - x: 0 - y: 8 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'availabilityResults/count' - aggregationType: 7 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Availability test results count' - color: '#47BDF5' - } - } - ] - title: 'Availability test results count' - visualization: { - chartType: 2 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - { - position: { - x: 4 - y: 8 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'performanceCounters/processIOBytesPerSecond' - aggregationType: 4 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Process IO rate' - color: '#47BDF5' - } - } - ] - title: 'Average process I/O rate' - visualization: { - chartType: 2 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - { - position: { - x: 8 - y: 8 - colSpan: 4 - rowSpan: 3 - } - metadata: { - inputs: [ - { - name: 'options' - value: { - chart: { - metrics: [ - { - resourceMetadata: { - id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' - } - name: 'performanceCounters/memoryAvailableBytes' - aggregationType: 4 - namespace: 'microsoft.insights/components' - metricVisualization: { - displayName: 'Available memory' - color: '#47BDF5' - } - } - ] - title: 'Average available memory' - visualization: { - chartType: 2 - legendVisualization: { - isVisible: true - position: 2 - hideSubtitle: false - } - axisVisualization: { - x: { - isVisible: true - axisType: 2 - } - y: { - isVisible: true - axisType: 1 - } - } - } - } - } - } - { - name: 'sharedTimeRange' - isOptional: true - } - ] - #disable-next-line BCP036 - type: 'Extension/HubsExtension/PartType/MonitorChartPart' - settings: {} - } - } - ] - } - ] - } -} - -resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = { - name: applicationInsightsName -} +metadata description = 'Creates a dashboard for an Application Insights instance.' +param name string +param applicationInsightsName string +param location string = resourceGroup().location +param tags object = {} + +// 2020-09-01-preview because that is the latest valid version +resource applicationInsightsDashboard 'Microsoft.Portal/dashboards@2020-09-01-preview' = { + name: name + location: location + tags: tags + properties: { + lenses: [ + { + order: 0 + parts: [ + { + position: { + x: 0 + y: 0 + colSpan: 2 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'id' + value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + { + name: 'Version' + value: '1.0' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/AspNetOverviewPinnedPart' + asset: { + idInputName: 'id' + type: 'ApplicationInsights' + } + defaultMenuItemId: 'overview' + } + } + { + position: { + x: 2 + y: 0 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'Version' + value: '1.0' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/ProactiveDetectionAsyncPart' + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + defaultMenuItemId: 'ProactiveDetection' + } + } + { + position: { + x: 3 + y: 0 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'ResourceId' + value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/QuickPulseButtonSmallPart' + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + } + } + { + position: { + x: 4 + y: 0 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'TimeContext' + value: { + durationMs: 86400000 + endTime: null + createdTime: '2018-05-04T01:20:33.345Z' + isInitialTime: true + grain: 1 + useDashboardTimeRange: false + } + } + { + name: 'Version' + value: '1.0' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/AvailabilityNavButtonPart' + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + } + } + { + position: { + x: 5 + y: 0 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'TimeContext' + value: { + durationMs: 86400000 + endTime: null + createdTime: '2018-05-08T18:47:35.237Z' + isInitialTime: true + grain: 1 + useDashboardTimeRange: false + } + } + { + name: 'ConfigurationId' + value: '78ce933e-e864-4b05-a27b-71fd55a6afad' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/AppMapButtonPart' + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + } + } + { + position: { + x: 0 + y: 1 + colSpan: 3 + rowSpan: 1 + } + metadata: { + inputs: [] + type: 'Extension/HubsExtension/PartType/MarkdownPart' + settings: { + content: { + settings: { + content: '# Usage' + title: '' + subtitle: '' + } + } + } + } + } + { + position: { + x: 3 + y: 1 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'TimeContext' + value: { + durationMs: 86400000 + endTime: null + createdTime: '2018-05-04T01:22:35.782Z' + isInitialTime: true + grain: 1 + useDashboardTimeRange: false + } + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/UsageUsersOverviewPart' + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + } + } + { + position: { + x: 4 + y: 1 + colSpan: 3 + rowSpan: 1 + } + metadata: { + inputs: [] + type: 'Extension/HubsExtension/PartType/MarkdownPart' + settings: { + content: { + settings: { + content: '# Reliability' + title: '' + subtitle: '' + } + } + } + } + } + { + position: { + x: 7 + y: 1 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ResourceId' + value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + { + name: 'DataModel' + value: { + version: '1.0.0' + timeContext: { + durationMs: 86400000 + createdTime: '2018-05-04T23:42:40.072Z' + isInitialTime: false + grain: 1 + useDashboardTimeRange: false + } + } + isOptional: true + } + { + name: 'ConfigurationId' + value: '8a02f7bf-ac0f-40e1-afe9-f0e72cfee77f' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/CuratedBladeFailuresPinnedPart' + isAdapter: true + asset: { + idInputName: 'ResourceId' + type: 'ApplicationInsights' + } + defaultMenuItemId: 'failures' + } + } + { + position: { + x: 8 + y: 1 + colSpan: 3 + rowSpan: 1 + } + metadata: { + inputs: [] + type: 'Extension/HubsExtension/PartType/MarkdownPart' + settings: { + content: { + settings: { + content: '# Responsiveness\r\n' + title: '' + subtitle: '' + } + } + } + } + } + { + position: { + x: 11 + y: 1 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ResourceId' + value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + { + name: 'DataModel' + value: { + version: '1.0.0' + timeContext: { + durationMs: 86400000 + createdTime: '2018-05-04T23:43:37.804Z' + isInitialTime: false + grain: 1 + useDashboardTimeRange: false + } + } + isOptional: true + } + { + name: 'ConfigurationId' + value: '2a8ede4f-2bee-4b9c-aed9-2db0e8a01865' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/CuratedBladePerformancePinnedPart' + isAdapter: true + asset: { + idInputName: 'ResourceId' + type: 'ApplicationInsights' + } + defaultMenuItemId: 'performance' + } + } + { + position: { + x: 12 + y: 1 + colSpan: 3 + rowSpan: 1 + } + metadata: { + inputs: [] + type: 'Extension/HubsExtension/PartType/MarkdownPart' + settings: { + content: { + settings: { + content: '# Browser' + title: '' + subtitle: '' + } + } + } + } + } + { + position: { + x: 15 + y: 1 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'MetricsExplorerJsonDefinitionId' + value: 'BrowserPerformanceTimelineMetrics' + } + { + name: 'TimeContext' + value: { + durationMs: 86400000 + createdTime: '2018-05-08T12:16:27.534Z' + isInitialTime: false + grain: 1 + useDashboardTimeRange: false + } + } + { + name: 'CurrentFilter' + value: { + eventTypes: [ + 4 + 1 + 3 + 5 + 2 + 6 + 13 + ] + typeFacets: {} + isPermissive: false + } + } + { + name: 'id' + value: { + Name: applicationInsights.name + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'Version' + value: '1.0' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/MetricsExplorerBladePinnedPart' + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + defaultMenuItemId: 'browser' + } + } + { + position: { + x: 0 + y: 2 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'sessions/count' + aggregationType: 5 + namespace: 'microsoft.insights/components/kusto' + metricVisualization: { + displayName: 'Sessions' + color: '#47BDF5' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'users/count' + aggregationType: 5 + namespace: 'microsoft.insights/components/kusto' + metricVisualization: { + displayName: 'Users' + color: '#7E58FF' + } + } + ] + title: 'Unique sessions and users' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + openBladeOnClick: { + openBlade: true + destinationBlade: { + extensionName: 'HubsExtension' + bladeName: 'ResourceMenuBlade' + parameters: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + menuid: 'segmentationUsers' + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 4 + y: 2 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'requests/failed' + aggregationType: 7 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Failed requests' + color: '#EC008C' + } + } + ] + title: 'Failed requests' + visualization: { + chartType: 3 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + openBladeOnClick: { + openBlade: true + destinationBlade: { + extensionName: 'HubsExtension' + bladeName: 'ResourceMenuBlade' + parameters: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + menuid: 'failures' + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 8 + y: 2 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'requests/duration' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Server response time' + color: '#00BCF2' + } + } + ] + title: 'Server response time' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + openBladeOnClick: { + openBlade: true + destinationBlade: { + extensionName: 'HubsExtension' + bladeName: 'ResourceMenuBlade' + parameters: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + menuid: 'performance' + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 12 + y: 2 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'browserTimings/networkDuration' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Page load network connect time' + color: '#7E58FF' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'browserTimings/processingDuration' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Client processing time' + color: '#44F1C8' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'browserTimings/sendDuration' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Send request time' + color: '#EB9371' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'browserTimings/receiveDuration' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Receiving response time' + color: '#0672F1' + } + } + ] + title: 'Average page load time breakdown' + visualization: { + chartType: 3 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 0 + y: 5 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'availabilityResults/availabilityPercentage' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Availability' + color: '#47BDF5' + } + } + ] + title: 'Average availability' + visualization: { + chartType: 3 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + openBladeOnClick: { + openBlade: true + destinationBlade: { + extensionName: 'HubsExtension' + bladeName: 'ResourceMenuBlade' + parameters: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + menuid: 'availability' + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 4 + y: 5 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'exceptions/server' + aggregationType: 7 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Server exceptions' + color: '#47BDF5' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'dependencies/failed' + aggregationType: 7 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Dependency failures' + color: '#7E58FF' + } + } + ] + title: 'Server exceptions and Dependency failures' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 8 + y: 5 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'performanceCounters/processorCpuPercentage' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Processor time' + color: '#47BDF5' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'performanceCounters/processCpuPercentage' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Process CPU' + color: '#7E58FF' + } + } + ] + title: 'Average processor and process CPU utilization' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 12 + y: 5 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'exceptions/browser' + aggregationType: 7 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Browser exceptions' + color: '#47BDF5' + } + } + ] + title: 'Browser exceptions' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 0 + y: 8 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'availabilityResults/count' + aggregationType: 7 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Availability test results count' + color: '#47BDF5' + } + } + ] + title: 'Availability test results count' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 4 + y: 8 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'performanceCounters/processIOBytesPerSecond' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Process IO rate' + color: '#47BDF5' + } + } + ] + title: 'Average process I/O rate' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 8 + y: 8 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsights.name}' + } + name: 'performanceCounters/memoryAvailableBytes' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Available memory' + color: '#47BDF5' + } + } + ] + title: 'Average available memory' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + ] + } + ] + } +} + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = { + name: applicationInsightsName +} diff --git a/08-agents/08-09/invoke.sh b/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/monitor/applicationinsights.bicep similarity index 97% rename from 08-agents/08-09/invoke.sh rename to 08-agents/08-10-hosted-copilot-sdk-agent/infra/core/monitor/applicationinsights.bicep index 18b6176..73240d1 100644 --- a/08-agents/08-09/invoke.sh +++ b/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/monitor/applicationinsights.bicep @@ -1,47 +1,47 @@ -metadata description = 'Creates an Application Insights instance based on an existing Log Analytics workspace.' -param name string -param dashboardName string = '' -param location string = resourceGroup().location -param tags object = {} -param logAnalyticsWorkspaceId string - -@description('Optional. Principal ID of the Foundry Project managed identity to grant Log Analytics Reader.') -param projectMIPrincipalId string = '' - -resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { - name: name - location: location - tags: tags - kind: 'web' - properties: { - Application_Type: 'web' - WorkspaceResourceId: logAnalyticsWorkspaceId - } -} - -module applicationInsightsDashboard 'applicationinsights-dashboard.bicep' = if (!empty(dashboardName)) { - name: 'application-insights-dashboard' - params: { - name: dashboardName - location: location - applicationInsightsName: applicationInsights.name - } -} - -// Log Analytics Reader for the Foundry Project managed identity. -// Required for running evaluations on traces generated by agents. -resource logAnalyticsReaderRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(projectMIPrincipalId)) { - scope: applicationInsights - name: guid(applicationInsights.id, projectMIPrincipalId, '73c42c96-874c-492b-b04d-ab87d138a893') - properties: { - principalId: projectMIPrincipalId - principalType: 'ServicePrincipal' - // Log Analytics Reader - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '73c42c96-874c-492b-b04d-ab87d138a893') - } -} - -output connectionString string = applicationInsights.properties.ConnectionString -output id string = applicationInsights.id -output instrumentationKey string = applicationInsights.properties.InstrumentationKey -output name string = applicationInsights.name +metadata description = 'Creates an Application Insights instance based on an existing Log Analytics workspace.' +param name string +param dashboardName string = '' +param location string = resourceGroup().location +param tags object = {} +param logAnalyticsWorkspaceId string + +@description('Optional. Principal ID of the Foundry Project managed identity to grant Log Analytics Reader.') +param projectMIPrincipalId string = '' + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { + name: name + location: location + tags: tags + kind: 'web' + properties: { + Application_Type: 'web' + WorkspaceResourceId: logAnalyticsWorkspaceId + } +} + +module applicationInsightsDashboard 'applicationinsights-dashboard.bicep' = if (!empty(dashboardName)) { + name: 'application-insights-dashboard' + params: { + name: dashboardName + location: location + applicationInsightsName: applicationInsights.name + } +} + +// Log Analytics Reader for the Foundry Project managed identity. +// Required for running evaluations on traces generated by agents. +resource logAnalyticsReaderRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(projectMIPrincipalId)) { + scope: applicationInsights + name: guid(applicationInsights.id, projectMIPrincipalId, '73c42c96-874c-492b-b04d-ab87d138a893') + properties: { + principalId: projectMIPrincipalId + principalType: 'ServicePrincipal' + // Log Analytics Reader + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '73c42c96-874c-492b-b04d-ab87d138a893') + } +} + +output connectionString string = applicationInsights.properties.ConnectionString +output id string = applicationInsights.id +output instrumentationKey string = applicationInsights.properties.InstrumentationKey +output name string = applicationInsights.name diff --git a/08-agents/08-09/azure.yaml b/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/monitor/loganalytics.bicep similarity index 95% rename from 08-agents/08-09/azure.yaml rename to 08-agents/08-10-hosted-copilot-sdk-agent/infra/core/monitor/loganalytics.bicep index bf87f54..33f9dc2 100644 --- a/08-agents/08-09/azure.yaml +++ b/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/monitor/loganalytics.bicep @@ -1,22 +1,22 @@ -metadata description = 'Creates a Log Analytics workspace.' -param name string -param location string = resourceGroup().location -param tags object = {} - -resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2021-12-01-preview' = { - name: name - location: location - tags: tags - properties: any({ - retentionInDays: 30 - features: { - searchVersion: 1 - } - sku: { - name: 'PerGB2018' - } - }) -} - -output id string = logAnalytics.id -output name string = logAnalytics.name +metadata description = 'Creates a Log Analytics workspace.' +param name string +param location string = resourceGroup().location +param tags object = {} + +resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2021-12-01-preview' = { + name: name + location: location + tags: tags + properties: any({ + retentionInDays: 30 + features: { + searchVersion: 1 + } + sku: { + name: 'PerGB2018' + } + }) +} + +output id string = logAnalytics.id +output name string = logAnalytics.name diff --git a/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/search/azure_ai_search.bicep b/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/search/azure_ai_search.bicep new file mode 100644 index 0000000..7bb8e63 --- /dev/null +++ b/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/search/azure_ai_search.bicep @@ -0,0 +1,211 @@ +targetScope = 'resourceGroup' + +@description('Tags that will be applied to all resources') +param tags object = {} + +@description('Azure Search resource name') +param resourceName string + +@description('Azure Search SKU name') +param azureSearchSkuName string = 'basic' + +@description('Azure storage account resource ID') +param storageAccountResourceId string + +@description('container name') +param containerName string = 'knowledgebase' + +@description('AI Services account name for the project parent') +param aiServicesAccountName string = '' + +@description('AI project name for creating the connection') +param aiProjectName string = '' + +@description('Id of the user or app to assign application roles') +param principalId string + +@description('Principal type of user or app') +param principalType string + +@description('Name for the AI Foundry search connection') +param connectionName string + +@description('Location for all resources') +param location string = resourceGroup().location + +// Get reference to the AI Services account and project to access their managed identities +resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: aiServicesAccountName + + resource aiProject 'projects' existing = { + name: aiProjectName + } +} + +// Azure Search Service +resource searchService 'Microsoft.Search/searchServices@2024-06-01-preview' = { + name: resourceName + location: location + tags: tags + sku: { + name: azureSearchSkuName + } + identity: { + type: 'SystemAssigned' + } + properties: { + replicaCount: 1 + partitionCount: 1 + hostingMode: 'default' + authOptions: { + aadOrApiKey: { + aadAuthFailureMode: 'http401WithBearerChallenge' + } + } + disableLocalAuth: false + encryptionWithCmk: { + enforcement: 'Unspecified' + } + publicNetworkAccess: 'enabled' + } +} + +// Reference to existing Storage Account +resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' existing = { + name: last(split(storageAccountResourceId, '/')) +} + +// Reference to existing Blob Service +resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-05-01' existing = { + parent: storageAccount + name: 'default' +} + +// Storage Container (create if it doesn't exist) +resource storageContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = { + parent: blobService + name: containerName + properties: { + publicAccess: 'None' + } +} + +// RBAC Assignments + +// Search needs to read from Storage +resource searchToStorageRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(storageAccount.id, searchService.id, 'Storage Blob Data Reader', uniqueString(deployment().name)) + scope: storageAccount + properties: { + // GOOD + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '2a2b9908-6ea1-4ae2-8e65-a410df84e7d1') // Storage Blob Data Reader + principalId: searchService.identity.principalId + principalType: 'ServicePrincipal' + } +} + +// Search needs OpenAI access (AI Services account) +resource searchToAIServicesRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(aiServicesAccountName)) { + name: guid(aiServicesAccountName, searchService.id, 'Cognitive Services OpenAI User', uniqueString(deployment().name)) + properties: { + // GOOD + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd') // Cognitive Services OpenAI User + principalId: searchService.identity.principalId + principalType: 'ServicePrincipal' + } +} + +// AI Project needs Search access - Service Contributor +resource aiServicesToSearchServiceRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: guid(searchService.id, aiServicesAccountName, aiProjectName, 'Search Service Contributor', uniqueString(deployment().name)) + scope: searchService + properties: { + // GOOD + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7ca78c08-252a-4471-8644-bb5ff32d4ba0') // Search Service Contributor + principalId: aiAccount::aiProject.identity.principalId + principalType: 'ServicePrincipal' + } +} + +// AI Project needs Search access - Index Data Contributor +resource aiServicesToSearchDataRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: guid(searchService.id, aiServicesAccountName, aiProjectName, 'Search Index Data Contributor', uniqueString(deployment().name)) + scope: searchService + properties: { + // GOOD + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '8ebe5a00-799e-43f5-93ac-243d3dce84a7') // Search Index Data Contributor + principalId: aiAccount::aiProject.identity.principalId + principalType: 'ServicePrincipal' + } +} + +// User permissions - Search Index Data Contributor +resource userToSearchRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(searchService.id, principalId, 'Search Index Data Contributor', uniqueString(deployment().name)) + scope: searchService + properties: { + // GOOD + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '8ebe5a00-799e-43f5-93ac-243d3dce84a7') // Search Index Data Contributor + principalId: principalId + principalType: principalType + } +} + +// // User permissions - Storage Blob Data Contributor +// resource userToStorageRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { +// name: guid(storageAccount.id, principalId, 'Storage Blob Data Contributor', uniqueString(deployment().name)) +// scope: storageAccount +// properties: { +// roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe') // Storage Blob Data Contributor +// principalId: principalId +// principalType: principalType +// } +// } + +// // Project needs Search access - Index Data Contributor +// resource projectToSearchRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { +// name: guid(searchService.id, aiProjectName, 'Search Index Data Contributor', uniqueString(deployment().name)) +// scope: searchService +// properties: { +// roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '8ebe5a00-799e-43f5-93ac-243d3dce84a7') // Search Index Data Contributor +// principalId: aiAccountPrincipalId // Using AI account principal ID as project identity +// principalType: 'ServicePrincipal' +// } +// } + +// Create the AI Search connection using the centralized connection module +module aiSearchConnection '../ai/connection.bicep' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: 'ai-search-connection-creation' + params: { + aiServicesAccountName: aiServicesAccountName + aiProjectName: aiProjectName + connectionConfig: { + name: connectionName + category: 'CognitiveSearch' + target: 'https://${searchService.name}.search.windows.net' + authType: 'AAD' + isSharedToAll: true + metadata: { + ApiVersion: '2024-07-01' + ResourceId: searchService.id + ApiType: 'Azure' + type: 'azure_ai_search' + } + } + } + dependsOn: [ + aiServicesToSearchDataRoleAssignment + ] +} + +// Outputs +output searchServiceName string = searchService.name +output searchServiceId string = searchService.id +output searchServicePrincipalId string = searchService.identity.principalId +output storageAccountName string = storageAccount.name +output storageAccountId string = storageAccount.id +output containerName string = storageContainer.name +output storageAccountPrincipalId string = storageAccount.identity.principalId +output searchConnectionName string = (!empty(aiServicesAccountName) && !empty(aiProjectName)) ? aiSearchConnection!.outputs.connectionName : '' +output searchConnectionId string = (!empty(aiServicesAccountName) && !empty(aiProjectName)) ? aiSearchConnection!.outputs.connectionId : '' + diff --git a/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/search/bing_custom_grounding.bicep b/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/search/bing_custom_grounding.bicep new file mode 100644 index 0000000..1fddea0 --- /dev/null +++ b/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/search/bing_custom_grounding.bicep @@ -0,0 +1,84 @@ +targetScope = 'resourceGroup' + +@description('Tags that will be applied to all resources') +param tags object = {} + +@description('Bing custom grounding resource name') +param resourceName string + +@description('AI Services account name for the project parent') +param aiServicesAccountName string = '' + +@description('AI project name for creating the connection') +param aiProjectName string = '' + +@description('Name for the AI Foundry Bing Custom Search connection') +param connectionName string + +// Get reference to the AI Services account and project to access their managed identities +resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: aiServicesAccountName + + resource aiProject 'projects' existing = { + name: aiProjectName + } +} + +// Bing Search resource for grounding capability +resource bingCustomSearch 'Microsoft.Bing/accounts@2020-06-10' = { + name: resourceName + location: 'global' + tags: tags + sku: { + name: 'G1' + } + properties: { + statisticsEnabled: false + } + kind: 'Bing.CustomGrounding' +} + +// Role assignment to allow AI project to use Bing Search +resource bingCustomSearchRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + scope: bingCustomSearch + name: guid(subscription().id, resourceGroup().id, 'bing-search-role', aiServicesAccountName, aiProjectName) + properties: { + principalId: aiAccount::aiProject.identity.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', 'a97b65f3-24c7-4388-baec-2e87135dc908') // Cognitive Services User + } +} + +// Create the Bing Custom Search connection using the centralized connection module +module aiSearchConnection '../ai/connection.bicep' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: 'bing-custom-search-connection-creation' + params: { + aiServicesAccountName: aiServicesAccountName + aiProjectName: aiProjectName + connectionConfig: { + name: connectionName + category: 'GroundingWithCustomSearch' + target: bingCustomSearch.properties.endpoint + authType: 'ApiKey' + isSharedToAll: true + metadata: { + Location: 'global' + ResourceId: bingCustomSearch.id + ApiType: 'Azure' + type: 'bing_custom_search' + } + } + credentials: { + key: bingCustomSearch.listKeys().key1 + } + } + dependsOn: [ + bingCustomSearchRoleAssignment + ] +} + +// Outputs +output bingCustomGroundingName string = bingCustomSearch.name +output bingCustomGroundingConnectionName string = aiSearchConnection.outputs.connectionName +output bingCustomGroundingResourceId string = bingCustomSearch.id +output bingCustomGroundingConnectionId string = aiSearchConnection.outputs.connectionId diff --git a/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/search/bing_grounding.bicep b/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/search/bing_grounding.bicep new file mode 100644 index 0000000..20ea5e9 --- /dev/null +++ b/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/search/bing_grounding.bicep @@ -0,0 +1,83 @@ +targetScope = 'resourceGroup' + +@description('Tags that will be applied to all resources') +param tags object = {} + +@description('Bing grounding resource name') +param resourceName string + +@description('AI Services account name for the project parent') +param aiServicesAccountName string = '' + +@description('AI project name for creating the connection') +param aiProjectName string = '' + +@description('Name for the AI Foundry Bing Search connection') +param connectionName string + +// Get reference to the AI Services account and project to access their managed identities +resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: aiServicesAccountName + + resource aiProject 'projects' existing = { + name: aiProjectName + } +} + +// Bing Search resource for grounding capability +resource bingSearch 'Microsoft.Bing/accounts@2020-06-10' = { + name: resourceName + location: 'global' + tags: tags + sku: { + name: 'G1' + } + properties: { + statisticsEnabled: false + } + kind: 'Bing.Grounding' +} + +// Role assignment to allow AI project to use Bing Search +resource bingSearchRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + scope: bingSearch + name: guid(subscription().id, resourceGroup().id, 'bing-search-role', aiServicesAccountName, aiProjectName) + properties: { + principalId: aiAccount::aiProject.identity.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', 'a97b65f3-24c7-4388-baec-2e87135dc908') // Cognitive Services User + } +} + +// Create the Bing Search connection using the centralized connection module +module bingSearchConnection '../ai/connection.bicep' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: 'bing-search-connection-creation' + params: { + aiServicesAccountName: aiServicesAccountName + aiProjectName: aiProjectName + connectionConfig: { + name: connectionName + category: 'GroundingWithBingSearch' + target: bingSearch.properties.endpoint + authType: 'ApiKey' + isSharedToAll: true + metadata: { + Location: 'global' + ResourceId: bingSearch.id + ApiType: 'Azure' + type: 'bing_grounding' + } + } + credentials: { + key: bingSearch.listKeys().key1 + } + } + dependsOn: [ + bingSearchRoleAssignment + ] +} + +output bingGroundingName string = bingSearch.name +output bingGroundingConnectionName string = bingSearchConnection.outputs.connectionName +output bingGroundingResourceId string = bingSearch.id +output bingGroundingConnectionId string = bingSearchConnection.outputs.connectionId diff --git a/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/storage/storage.bicep b/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/storage/storage.bicep new file mode 100644 index 0000000..18d9535 --- /dev/null +++ b/08-agents/08-10-hosted-copilot-sdk-agent/infra/core/storage/storage.bicep @@ -0,0 +1,113 @@ +targetScope = 'resourceGroup' + +@description('The location used for all deployed resources') +param location string = resourceGroup().location + +@description('Tags that will be applied to all resources') +param tags object = {} + +@description('Storage account resource name') +param resourceName string + +@description('Id of the user or app to assign application roles') +param principalId string + +@description('Principal type of user or app') +param principalType string + +@description('AI Services account name for the project parent') +param aiServicesAccountName string = '' + +@description('AI project name for creating the connection') +param aiProjectName string = '' + +@description('Name for the AI Foundry storage connection') +param connectionName string + +// Storage Account for the AI Services account +resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' = { + name: resourceName + location: location + tags: tags + sku: { + name: 'Standard_LRS' + } + kind: 'StorageV2' + identity: { + type: 'SystemAssigned' + } + properties: { + supportsHttpsTrafficOnly: true + allowBlobPublicAccess: false + minimumTlsVersion: 'TLS1_2' + accessTier: 'Hot' + encryption: { + services: { + blob: { + enabled: true + } + file: { + enabled: true + } + } + keySource: 'Microsoft.Storage' + } + } +} + +// Get reference to the AI Services account and project to access their managed identities +resource aiAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: aiServicesAccountName + + resource aiProject 'projects' existing = { + name: aiProjectName + } +} + +// Role assignment for AI Services to access the storage account +resource storageRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: guid(storageAccount.id, aiAccount.id, 'ai-storage-contributor') + scope: storageAccount + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe') // Storage Blob Data Contributor + principalId: aiAccount::aiProject.identity.principalId + principalType: 'ServicePrincipal' + } +} + +// User permissions - Storage Blob Data Contributor +resource userStorageRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(storageAccount.id, principalId, 'Storage Blob Data Contributor') + scope: storageAccount + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe') // Storage Blob Data Contributor + principalId: principalId + principalType: principalType + } +} + +// Create the storage connection using the centralized connection module +module storageConnection '../ai/connection.bicep' = if (!empty(aiServicesAccountName) && !empty(aiProjectName)) { + name: 'storage-connection-creation' + params: { + aiServicesAccountName: aiServicesAccountName + aiProjectName: aiProjectName + connectionConfig: { + name: connectionName + category: 'AzureStorageAccount' + target: storageAccount.properties.primaryEndpoints.blob + authType: 'AAD' + isSharedToAll: true + metadata: { + ApiType: 'Azure' + ResourceId: storageAccount.id + location: storageAccount.location + } + } + } +} + +output storageAccountName string = storageAccount.name +output storageAccountId string = storageAccount.id +output storageAccountPrincipalId string = storageAccount.identity.principalId +output storageConnectionName string = storageConnection.outputs.connectionName diff --git a/08-agents/08-09/env.example b/08-agents/08-10-hosted-copilot-sdk-agent/infra/main.bicep similarity index 98% rename from 08-agents/08-09/env.example rename to 08-agents/08-10-hosted-copilot-sdk-agent/infra/main.bicep index 6e8d7b6..2aa3356 100644 --- a/08-agents/08-09/env.example +++ b/08-agents/08-10-hosted-copilot-sdk-agent/infra/main.bicep @@ -1,237 +1,237 @@ -targetScope = 'subscription' -// targetScope = 'resourceGroup' - -@minLength(1) -@maxLength(64) -@description('Name of the environment that can be used as part of naming resource convention') -param environmentName string - -@minLength(1) -@maxLength(90) -@description('Name of the resource group to use or create') -param resourceGroupName string = 'rg-${environmentName}' - -// Restricted locations to match list from -// https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/responses?tabs=python-key#region-availability -@minLength(1) -@description('Primary location for all resources') -@allowed([ - 'australiaeast' - 'brazilsouth' - 'canadacentral' - 'canadaeast' - 'eastus' - 'eastus2' - 'francecentral' - 'germanywestcentral' - 'italynorth' - 'japaneast' - 'koreacentral' - 'northcentralus' - 'norwayeast' - 'polandcentral' - 'southafricanorth' - 'southcentralus' - 'southeastasia' - 'southindia' - 'spaincentral' - 'swedencentral' - 'switzerlandnorth' - 'uaenorth' - 'uksouth' - 'westus' - 'westus2' - 'westus3' -]) -param location string - -param aiDeploymentsLocation string - -@description('Id of the user or app to assign application roles') -param principalId string - -@description('Principal type of user or app') -param principalType string - -@description('Optional. Name of an existing AI Services account within the resource group. If not provided, a new one will be created.') -param aiFoundryResourceName string = '' - -@description('Optional. Name of the AI Foundry project. If not provided, a default name will be used.') -param aiFoundryProjectName string = 'ai-project-${environmentName}' - -@description('List of model deployments') -param aiProjectDeploymentsJson string = '[]' - -@description('List of connections') -param aiProjectConnectionsJson string = '[]' - -@secure() -@description('JSON map of connection name to credentials object. Example: {"my-conn":{"key":"secret"}}') -param aiProjectConnectionCredentialsJson string = '{}' - -@description('List of resources to create and connect to the AI project') -param aiProjectDependentResourcesJson string = '[]' - -var aiProjectDeployments = json(aiProjectDeploymentsJson) -var aiProjectConnections = json(aiProjectConnectionsJson) -var aiProjectConnectionCreds = json(aiProjectConnectionCredentialsJson) -var aiProjectDependentResources = json(aiProjectDependentResourcesJson) - -@description('Enable hosted agent deployment') -param enableHostedAgents bool - -@description('Enable the capability host for supporting BYO storage of agent conversations. When false and hosted agents are enabled, the capability host is not created.') -param enableCapabilityHost bool - -@description('Enable monitoring for the AI project') -param enableMonitoring bool - -@description('When true, skip Foundry project/role/connection provisioning and reference the existing project read-only. Use when pointing at an existing Foundry project via --project-id.') -param useExistingAiProject bool = false - -@description('Optional. Existing container registry resource ID. If provided, no new ACR will be created and a connection to this ACR will be established.') -param existingContainerRegistryResourceId string = '' - -@description('Optional. Existing container registry endpoint (login server). Required if existingContainerRegistryResourceId is provided.') -param existingContainerRegistryEndpoint string = '' - -@description('Optional. Name of an existing ACR connection on the Foundry project. If provided, no new ACR or connection will be created.') -param existingAcrConnectionName string = '' - -@description('Optional. Existing Application Insights connection string. If provided, a connection will be created but no new App Insights resource.') -param existingApplicationInsightsConnectionString string = '' - -@description('Optional. Existing Application Insights resource ID. Used for connection metadata when providing an existing App Insights.') -param existingApplicationInsightsResourceId string = '' - -@description('Optional. Name of an existing Application Insights connection on the Foundry project. If provided, no new App Insights or connection will be created.') -param existingAppInsightsConnectionName string = '' - -// Tags that should be applied to all resources. -// -// Note that 'azd-service-name' tags should be applied separately to service host resources. -// Example usage: -// tags: union(tags, { 'azd-service-name': }) -var tags = { - 'azd-env-name': environmentName -} - -// Check if resource group exists and create it if it doesn't -resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { - name: resourceGroupName - location: location - tags: tags -} - -// Build dependent resources array conditionally -// Check if ACR already exists in the user-provided array to avoid duplicates -// Also skip if user provided an existing container registry endpoint or connection name -var hasAcr = contains(map(aiProjectDependentResources, r => r.resource), 'registry') -var shouldCreateAcr = enableHostedAgents && !hasAcr && empty(existingContainerRegistryResourceId) && empty(existingAcrConnectionName) -var dependentResources = shouldCreateAcr ? union(aiProjectDependentResources, [ - { - resource: 'registry' - connectionName: 'acr-${uniqueString(subscription().id, resourceGroupName, location)}' - } -]) : aiProjectDependentResources - -// AI Project module — only when creating new resources -module aiProject 'core/ai/ai-project.bicep' = if (!useExistingAiProject) { - scope: rg - name: 'ai-project' - params: { - tags: tags - location: aiDeploymentsLocation - aiFoundryProjectName: aiFoundryProjectName - principalId: principalId - principalType: principalType - existingAiAccountName: aiFoundryResourceName - deployments: aiProjectDeployments - connections: aiProjectConnections - connectionCredentials: aiProjectConnectionCreds - additionalDependentResources: dependentResources - enableMonitoring: enableMonitoring - enableHostedAgents: enableHostedAgents - enableCapabilityHost: enableCapabilityHost - existingContainerRegistryResourceId: existingContainerRegistryResourceId - existingContainerRegistryEndpoint: existingContainerRegistryEndpoint - existingAcrConnectionName: existingAcrConnectionName - existingApplicationInsightsConnectionString: existingApplicationInsightsConnectionString - existingApplicationInsightsResourceId: existingApplicationInsightsResourceId - existingAppInsightsConnectionName: existingAppInsightsConnectionName - } -} - -// Existing project module — read-only reference when reusing an existing Foundry project -module existingAiProject 'core/ai/existing-ai-project.bicep' = if (useExistingAiProject) { - scope: rg - name: 'existing-ai-project' - params: { - aiServicesAccountName: aiFoundryResourceName - aiFoundryProjectName: aiFoundryProjectName - existingAcrConnectionName: existingAcrConnectionName - existingContainerRegistryEndpoint: existingContainerRegistryEndpoint - existingApplicationInsightsConnectionString: existingApplicationInsightsConnectionString - existingApplicationInsightsResourceId: existingApplicationInsightsResourceId - } -} - -// ACR for existing project — create when hosted agents need a registry but the existing project has none -var shouldCreateAcrForExistingProject = useExistingAiProject && shouldCreateAcr -var acrConnectionName = 'acr-${uniqueString(subscription().id, resourceGroupName, location)}' - -module acrForExistingProject 'core/host/acr.bicep' = if (shouldCreateAcrForExistingProject) { - scope: rg - name: 'acr-for-existing-project' - params: { - location: location - tags: tags - resourceName: 'cr${uniqueString(subscription().id, resourceGroupName, location)}' - connectionName: acrConnectionName - principalId: principalId - principalType: principalType - aiServicesAccountName: aiFoundryResourceName - aiProjectName: aiFoundryProjectName - } -} - -// Resources -output AZURE_RESOURCE_GROUP string = resourceGroupName -output AZURE_AI_ACCOUNT_ID string = useExistingAiProject ? existingAiProject.outputs.accountId : aiProject.outputs.accountId -output AZURE_AI_PROJECT_ID string = useExistingAiProject ? existingAiProject.outputs.projectId : aiProject.outputs.projectId -output AZURE_AI_FOUNDRY_PROJECT_ID string = useExistingAiProject ? existingAiProject.outputs.projectId : aiProject.outputs.projectId -output AZURE_AI_ACCOUNT_NAME string = useExistingAiProject ? existingAiProject.outputs.aiServicesAccountName : aiProject.outputs.aiServicesAccountName -output AZURE_AI_PROJECT_NAME string = useExistingAiProject ? existingAiProject.outputs.projectName : aiProject.outputs.projectName - -// Endpoints -output AZURE_AI_PROJECT_ENDPOINT string = useExistingAiProject ? existingAiProject.outputs.AZURE_AI_PROJECT_ENDPOINT : aiProject.outputs.AZURE_AI_PROJECT_ENDPOINT -output AZURE_OPENAI_ENDPOINT string = useExistingAiProject ? existingAiProject.outputs.AZURE_OPENAI_ENDPOINT : aiProject.outputs.AZURE_OPENAI_ENDPOINT -output APPLICATIONINSIGHTS_CONNECTION_STRING string = useExistingAiProject ? existingAiProject.outputs.APPLICATIONINSIGHTS_CONNECTION_STRING : aiProject.outputs.APPLICATIONINSIGHTS_CONNECTION_STRING -output APPLICATIONINSIGHTS_RESOURCE_ID string = useExistingAiProject ? existingAiProject.outputs.APPLICATIONINSIGHTS_RESOURCE_ID : aiProject.outputs.APPLICATIONINSIGHTS_RESOURCE_ID - -// Dependent Resources and Connections - -// ACR -output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = shouldCreateAcrForExistingProject ? acrForExistingProject.outputs.containerRegistryConnectionName : (useExistingAiProject ? existingAiProject.outputs.dependentResources.registry.connectionName : aiProject.outputs.dependentResources.registry.connectionName) -output AZURE_CONTAINER_REGISTRY_ENDPOINT string = shouldCreateAcrForExistingProject ? acrForExistingProject.outputs.containerRegistryLoginServer : (useExistingAiProject ? existingAiProject.outputs.dependentResources.registry.loginServer : aiProject.outputs.dependentResources.registry.loginServer) - -// Bing Search -output BING_GROUNDING_CONNECTION_NAME string = useExistingAiProject ? existingAiProject.outputs.dependentResources.bing_grounding.connectionName : aiProject.outputs.dependentResources.bing_grounding.connectionName -output BING_GROUNDING_RESOURCE_NAME string = useExistingAiProject ? existingAiProject.outputs.dependentResources.bing_grounding.name : aiProject.outputs.dependentResources.bing_grounding.name -output BING_GROUNDING_CONNECTION_ID string = useExistingAiProject ? existingAiProject.outputs.dependentResources.bing_grounding.connectionId : aiProject.outputs.dependentResources.bing_grounding.connectionId - -// Bing Custom Search -output BING_CUSTOM_GROUNDING_CONNECTION_NAME string = useExistingAiProject ? existingAiProject.outputs.dependentResources.bing_custom_grounding.connectionName : aiProject.outputs.dependentResources.bing_custom_grounding.connectionName -output BING_CUSTOM_GROUNDING_NAME string = useExistingAiProject ? existingAiProject.outputs.dependentResources.bing_custom_grounding.name : aiProject.outputs.dependentResources.bing_custom_grounding.name -output BING_CUSTOM_GROUNDING_CONNECTION_ID string = useExistingAiProject ? existingAiProject.outputs.dependentResources.bing_custom_grounding.connectionId : aiProject.outputs.dependentResources.bing_custom_grounding.connectionId - -// Azure AI Search -output AZURE_AI_SEARCH_CONNECTION_NAME string = useExistingAiProject ? existingAiProject.outputs.dependentResources.search.connectionName : aiProject.outputs.dependentResources.search.connectionName -output AZURE_AI_SEARCH_SERVICE_NAME string = useExistingAiProject ? existingAiProject.outputs.dependentResources.search.serviceName : aiProject.outputs.dependentResources.search.serviceName - -// Azure Storage -output AZURE_STORAGE_CONNECTION_NAME string = useExistingAiProject ? existingAiProject.outputs.dependentResources.storage.connectionName : aiProject.outputs.dependentResources.storage.connectionName -output AZURE_STORAGE_ACCOUNT_NAME string = useExistingAiProject ? existingAiProject.outputs.dependentResources.storage.accountName : aiProject.outputs.dependentResources.storage.accountName - -// Connections -output AI_PROJECT_CONNECTION_IDS_JSON string = useExistingAiProject ? string(existingAiProject.outputs.connectionIds) : string(aiProject.outputs.connectionIds) +targetScope = 'subscription' +// targetScope = 'resourceGroup' + +@minLength(1) +@maxLength(64) +@description('Name of the environment that can be used as part of naming resource convention') +param environmentName string + +@minLength(1) +@maxLength(90) +@description('Name of the resource group to use or create') +param resourceGroupName string = 'rg-${environmentName}' + +// Restricted locations to match list from +// https://learn.microsoft.com/en-us/azure/ai-foundry/openai/how-to/responses?tabs=python-key#region-availability +@minLength(1) +@description('Primary location for all resources') +@allowed([ + 'australiaeast' + 'brazilsouth' + 'canadacentral' + 'canadaeast' + 'eastus' + 'eastus2' + 'francecentral' + 'germanywestcentral' + 'italynorth' + 'japaneast' + 'koreacentral' + 'northcentralus' + 'norwayeast' + 'polandcentral' + 'southafricanorth' + 'southcentralus' + 'southeastasia' + 'southindia' + 'spaincentral' + 'swedencentral' + 'switzerlandnorth' + 'uaenorth' + 'uksouth' + 'westus' + 'westus2' + 'westus3' +]) +param location string + +param aiDeploymentsLocation string + +@description('Id of the user or app to assign application roles') +param principalId string + +@description('Principal type of user or app') +param principalType string + +@description('Optional. Name of an existing AI Services account within the resource group. If not provided, a new one will be created.') +param aiFoundryResourceName string = '' + +@description('Optional. Name of the AI Foundry project. If not provided, a default name will be used.') +param aiFoundryProjectName string = 'ai-project-${environmentName}' + +@description('List of model deployments') +param aiProjectDeploymentsJson string = '[]' + +@description('List of connections') +param aiProjectConnectionsJson string = '[]' + +@secure() +@description('JSON map of connection name to credentials object. Example: {"my-conn":{"key":"secret"}}') +param aiProjectConnectionCredentialsJson string = '{}' + +@description('List of resources to create and connect to the AI project') +param aiProjectDependentResourcesJson string = '[]' + +var aiProjectDeployments = json(aiProjectDeploymentsJson) +var aiProjectConnections = json(aiProjectConnectionsJson) +var aiProjectConnectionCreds = json(aiProjectConnectionCredentialsJson) +var aiProjectDependentResources = json(aiProjectDependentResourcesJson) + +@description('Enable hosted agent deployment') +param enableHostedAgents bool + +@description('Enable the capability host for supporting BYO storage of agent conversations. When false and hosted agents are enabled, the capability host is not created.') +param enableCapabilityHost bool + +@description('Enable monitoring for the AI project') +param enableMonitoring bool + +@description('When true, skip Foundry project/role/connection provisioning and reference the existing project read-only. Use when pointing at an existing Foundry project via --project-id.') +param useExistingAiProject bool = false + +@description('Optional. Existing container registry resource ID. If provided, no new ACR will be created and a connection to this ACR will be established.') +param existingContainerRegistryResourceId string = '' + +@description('Optional. Existing container registry endpoint (login server). Required if existingContainerRegistryResourceId is provided.') +param existingContainerRegistryEndpoint string = '' + +@description('Optional. Name of an existing ACR connection on the Foundry project. If provided, no new ACR or connection will be created.') +param existingAcrConnectionName string = '' + +@description('Optional. Existing Application Insights connection string. If provided, a connection will be created but no new App Insights resource.') +param existingApplicationInsightsConnectionString string = '' + +@description('Optional. Existing Application Insights resource ID. Used for connection metadata when providing an existing App Insights.') +param existingApplicationInsightsResourceId string = '' + +@description('Optional. Name of an existing Application Insights connection on the Foundry project. If provided, no new App Insights or connection will be created.') +param existingAppInsightsConnectionName string = '' + +// Tags that should be applied to all resources. +// +// Note that 'azd-service-name' tags should be applied separately to service host resources. +// Example usage: +// tags: union(tags, { 'azd-service-name': }) +var tags = { + 'azd-env-name': environmentName +} + +// Check if resource group exists and create it if it doesn't +resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { + name: resourceGroupName + location: location + tags: tags +} + +// Build dependent resources array conditionally +// Check if ACR already exists in the user-provided array to avoid duplicates +// Also skip if user provided an existing container registry endpoint or connection name +var hasAcr = contains(map(aiProjectDependentResources, r => r.resource), 'registry') +var shouldCreateAcr = enableHostedAgents && !hasAcr && empty(existingContainerRegistryResourceId) && empty(existingAcrConnectionName) +var dependentResources = shouldCreateAcr ? union(aiProjectDependentResources, [ + { + resource: 'registry' + connectionName: 'acr-${uniqueString(subscription().id, resourceGroupName, location)}' + } +]) : aiProjectDependentResources + +// AI Project module — only when creating new resources +module aiProject 'core/ai/ai-project.bicep' = if (!useExistingAiProject) { + scope: rg + name: 'ai-project' + params: { + tags: tags + location: aiDeploymentsLocation + aiFoundryProjectName: aiFoundryProjectName + principalId: principalId + principalType: principalType + existingAiAccountName: aiFoundryResourceName + deployments: aiProjectDeployments + connections: aiProjectConnections + connectionCredentials: aiProjectConnectionCreds + additionalDependentResources: dependentResources + enableMonitoring: enableMonitoring + enableHostedAgents: enableHostedAgents + enableCapabilityHost: enableCapabilityHost + existingContainerRegistryResourceId: existingContainerRegistryResourceId + existingContainerRegistryEndpoint: existingContainerRegistryEndpoint + existingAcrConnectionName: existingAcrConnectionName + existingApplicationInsightsConnectionString: existingApplicationInsightsConnectionString + existingApplicationInsightsResourceId: existingApplicationInsightsResourceId + existingAppInsightsConnectionName: existingAppInsightsConnectionName + } +} + +// Existing project module — read-only reference when reusing an existing Foundry project +module existingAiProject 'core/ai/existing-ai-project.bicep' = if (useExistingAiProject) { + scope: rg + name: 'existing-ai-project' + params: { + aiServicesAccountName: aiFoundryResourceName + aiFoundryProjectName: aiFoundryProjectName + existingAcrConnectionName: existingAcrConnectionName + existingContainerRegistryEndpoint: existingContainerRegistryEndpoint + existingApplicationInsightsConnectionString: existingApplicationInsightsConnectionString + existingApplicationInsightsResourceId: existingApplicationInsightsResourceId + } +} + +// ACR for existing project — create when hosted agents need a registry but the existing project has none +var shouldCreateAcrForExistingProject = useExistingAiProject && shouldCreateAcr +var acrConnectionName = 'acr-${uniqueString(subscription().id, resourceGroupName, location)}' + +module acrForExistingProject 'core/host/acr.bicep' = if (shouldCreateAcrForExistingProject) { + scope: rg + name: 'acr-for-existing-project' + params: { + location: location + tags: tags + resourceName: 'cr${uniqueString(subscription().id, resourceGroupName, location)}' + connectionName: acrConnectionName + principalId: principalId + principalType: principalType + aiServicesAccountName: aiFoundryResourceName + aiProjectName: aiFoundryProjectName + } +} + +// Resources +output AZURE_RESOURCE_GROUP string = resourceGroupName +output AZURE_AI_ACCOUNT_ID string = useExistingAiProject ? existingAiProject.outputs.accountId : aiProject.outputs.accountId +output AZURE_AI_PROJECT_ID string = useExistingAiProject ? existingAiProject.outputs.projectId : aiProject.outputs.projectId +output AZURE_AI_FOUNDRY_PROJECT_ID string = useExistingAiProject ? existingAiProject.outputs.projectId : aiProject.outputs.projectId +output AZURE_AI_ACCOUNT_NAME string = useExistingAiProject ? existingAiProject.outputs.aiServicesAccountName : aiProject.outputs.aiServicesAccountName +output AZURE_AI_PROJECT_NAME string = useExistingAiProject ? existingAiProject.outputs.projectName : aiProject.outputs.projectName + +// Endpoints +output AZURE_AI_PROJECT_ENDPOINT string = useExistingAiProject ? existingAiProject.outputs.AZURE_AI_PROJECT_ENDPOINT : aiProject.outputs.AZURE_AI_PROJECT_ENDPOINT +output AZURE_OPENAI_ENDPOINT string = useExistingAiProject ? existingAiProject.outputs.AZURE_OPENAI_ENDPOINT : aiProject.outputs.AZURE_OPENAI_ENDPOINT +output APPLICATIONINSIGHTS_CONNECTION_STRING string = useExistingAiProject ? existingAiProject.outputs.APPLICATIONINSIGHTS_CONNECTION_STRING : aiProject.outputs.APPLICATIONINSIGHTS_CONNECTION_STRING +output APPLICATIONINSIGHTS_RESOURCE_ID string = useExistingAiProject ? existingAiProject.outputs.APPLICATIONINSIGHTS_RESOURCE_ID : aiProject.outputs.APPLICATIONINSIGHTS_RESOURCE_ID + +// Dependent Resources and Connections + +// ACR +output AZURE_AI_PROJECT_ACR_CONNECTION_NAME string = shouldCreateAcrForExistingProject ? acrForExistingProject.outputs.containerRegistryConnectionName : (useExistingAiProject ? existingAiProject.outputs.dependentResources.registry.connectionName : aiProject.outputs.dependentResources.registry.connectionName) +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = shouldCreateAcrForExistingProject ? acrForExistingProject.outputs.containerRegistryLoginServer : (useExistingAiProject ? existingAiProject.outputs.dependentResources.registry.loginServer : aiProject.outputs.dependentResources.registry.loginServer) + +// Bing Search +output BING_GROUNDING_CONNECTION_NAME string = useExistingAiProject ? existingAiProject.outputs.dependentResources.bing_grounding.connectionName : aiProject.outputs.dependentResources.bing_grounding.connectionName +output BING_GROUNDING_RESOURCE_NAME string = useExistingAiProject ? existingAiProject.outputs.dependentResources.bing_grounding.name : aiProject.outputs.dependentResources.bing_grounding.name +output BING_GROUNDING_CONNECTION_ID string = useExistingAiProject ? existingAiProject.outputs.dependentResources.bing_grounding.connectionId : aiProject.outputs.dependentResources.bing_grounding.connectionId + +// Bing Custom Search +output BING_CUSTOM_GROUNDING_CONNECTION_NAME string = useExistingAiProject ? existingAiProject.outputs.dependentResources.bing_custom_grounding.connectionName : aiProject.outputs.dependentResources.bing_custom_grounding.connectionName +output BING_CUSTOM_GROUNDING_NAME string = useExistingAiProject ? existingAiProject.outputs.dependentResources.bing_custom_grounding.name : aiProject.outputs.dependentResources.bing_custom_grounding.name +output BING_CUSTOM_GROUNDING_CONNECTION_ID string = useExistingAiProject ? existingAiProject.outputs.dependentResources.bing_custom_grounding.connectionId : aiProject.outputs.dependentResources.bing_custom_grounding.connectionId + +// Azure AI Search +output AZURE_AI_SEARCH_CONNECTION_NAME string = useExistingAiProject ? existingAiProject.outputs.dependentResources.search.connectionName : aiProject.outputs.dependentResources.search.connectionName +output AZURE_AI_SEARCH_SERVICE_NAME string = useExistingAiProject ? existingAiProject.outputs.dependentResources.search.serviceName : aiProject.outputs.dependentResources.search.serviceName + +// Azure Storage +output AZURE_STORAGE_CONNECTION_NAME string = useExistingAiProject ? existingAiProject.outputs.dependentResources.storage.connectionName : aiProject.outputs.dependentResources.storage.connectionName +output AZURE_STORAGE_ACCOUNT_NAME string = useExistingAiProject ? existingAiProject.outputs.dependentResources.storage.accountName : aiProject.outputs.dependentResources.storage.accountName + +// Connections +output AI_PROJECT_CONNECTION_IDS_JSON string = useExistingAiProject ? string(existingAiProject.outputs.connectionIds) : string(aiProject.outputs.connectionIds) diff --git a/08-agents/08-10-hosted-copilot-sdk-agent/src/github-copilot-invocations/.dockerignore b/08-agents/08-10-hosted-copilot-sdk-agent/src/github-copilot-invocations/.dockerignore new file mode 100644 index 0000000..b709ec7 --- /dev/null +++ b/08-agents/08-10-hosted-copilot-sdk-agent/src/github-copilot-invocations/.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/08-agents/08-10-hosted-copilot-sdk-agent/src/github-copilot-invocations/Dockerfile b/08-agents/08-10-hosted-copilot-sdk-agent/src/github-copilot-invocations/Dockerfile new file mode 100644 index 0000000..33c5468 --- /dev/null +++ b/08-agents/08-10-hosted-copilot-sdk-agent/src/github-copilot-invocations/Dockerfile @@ -0,0 +1,25 @@ +# Pulled from Microsoft Container Registry instead of Docker Hub to avoid +# the unauthenticated pull rate limit that breaks ACR remote builds with +# `toomanyrequests: You have reached your unauthenticated pull rate limit`. +# MCR's devcontainers/python image is Microsoft-hosted, so ACR can pull it +# without anonymous-rate throttling. The image is bigger than python:3.12-slim +# (it bundles dev tools), but for a hosted-agent demo that's an acceptable +# tradeoff for a reliable build. +FROM mcr.microsoft.com/devcontainers/python:3.12-bookworm + +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/08-agents/08-10-hosted-copilot-sdk-agent/src/github-copilot-invocations/README.md b/08-agents/08-10-hosted-copilot-sdk-agent/src/github-copilot-invocations/README.md new file mode 100644 index 0000000..a42fcf9 --- /dev/null +++ b/08-agents/08-10-hosted-copilot-sdk-agent/src/github-copilot-invocations/README.md @@ -0,0 +1,150 @@ +**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. + +# GitHub Copilot SDK — Invocations Protocol (Streaming) + +A minimal getting-started agent using the [GitHub Copilot SDK](https://pypi.org/project/github-copilot-sdk/) (`CopilotClient`) with the [azure-ai-agentserver-invocations](https://pypi.org/project/azure-ai-agentserver-invocations/) protocol. Streams raw Copilot SDK session events as SSE with multi-turn support. + +> Deploying this agent to Microsoft Foundry is driven by the lab notebook [`08-10-01-deploy-hosted-copilot-sdk-agent.ipynb`](../../08-10-01-deploy-hosted-copilot-sdk-agent.ipynb), which builds the container image with `az acr build` and registers it via the `azure-ai-projects` SDK. This README documents the agent itself and how to run it locally. + +## How It Works + +1. Receives `{"input": "..."}` via `POST /invocations` +2. On first request, tries to resume a persisted Copilot session (by `FOUNDRY_AGENT_SESSION_ID`); if none exists, creates a new one +3. Each `SessionEvent` from the Copilot SDK is streamed back as an SSE `data:` event using `event.to_dict()` +4. A final `event: done` signal marks the end of the response +5. The session is cached in memory and reused across requests for multi-turn conversation +6. Skills in the `skills/` directory are auto-loaded — e.g. the included `m365-license-analytics` skill supplies the M365 license analysis method + +## Environment Variables + +This agent supports two LLM backends. Configure one of the following: + +| Variable | Required | Description | +|----------|----------|-------------| +| `GITHUB_TOKEN` | For Copilot model | GitHub fine-grained PAT with **Copilot Requests → Read-only** permission | +| `AZURE_AI_PROJECT_ENDPOINT` | For Foundry model | The Foundry project endpoint (`https://.services.ai.azure.com/api/projects/`); `main.py` appends `/openai/v1/` as the provider base URL. Auto-injected as `FOUNDRY_PROJECT_ENDPOINT` when hosted | +| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | For Foundry model | Model deployment name (e.g. `gpt-5.4-mini`) | +| `FOUNDRY_AGENT_SESSION_ID` | No | Session ID for persistence/resume. If unset, a UUID is generated | + +**How the agent selects its LLM backend** (`_byok_provider` in `main.py`): +- If `AZURE_AI_PROJECT_ENDPOINT` (or the auto-injected `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 + +- Python 3.10+ +- A GitHub fine-grained PAT (`github_pat_` prefix) for the Copilot-model path + +Create one at [github.com/settings/personal-access-tokens/new](https://github.com/settings/personal-access-tokens/new) with **Account permissions → Copilot Requests → Read-only**. + +> **Note:** Classic tokens (`ghp_`) are not supported. Use a fine-grained PAT (`github_pat_`), OAuth token (`gho_`), or GitHub App user token (`ghu_`). + +### Start the agent + +```bash +pip install -r requirements.txt +export GITHUB_TOKEN=github_pat_... # Copilot-model path +python main.py +``` + +The agent starts on `http://localhost:8088/`. + +To use a Foundry model instead of the Copilot model, set the Foundry variables (no `GITHUB_TOKEN` needed): + +```bash +export AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +export AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-5.4-mini +python main.py +``` + +Authentication uses Managed Identity via `DefaultAzureCredential`. When deployed as a hosted agent, the lab notebook injects `AZURE_AI_PROJECT_ENDPOINT`, `AZURE_AI_MODEL_DEPLOYMENT_NAME`, and `AZURE_CLIENT_ID` for you (and the platform auto-injects `FOUNDRY_PROJECT_ENDPOINT`). + +### Test with curl + +```bash +# First message +curl -N -X POST http://localhost:8088/invocations \ + -H "Content-Type: application/json" \ + -d '{"input": "What is Python?"}' + +# Follow-up (multi-turn — same session remembers context) +curl -N -X POST http://localhost:8088/invocations \ + -H "Content-Type: application/json" \ + -d '{"input": "Give me a code example"}' +``` + +### SSE Event Format + +Each Copilot SDK event is streamed via `event.to_dict()`: + +``` +data: {"type": "assistant.message_delta", "data": {"delta_content": "Python is"}}\n\n +data: {"type": "assistant.message_delta", "data": {"delta_content": " a programming"}}\n\n +... +event: done +data: {"invocation_id": "...", "session_id": "..."} +``` + +## Customizing the Agent + +This agent has two extension surfaces — pick the one that matches your need: + +| File | Purpose | Example | +|------|---------|---------| +| `system_prompt.md` | **Persona / global policy** that applies to every turn. Appended to the Copilot CLI's built-in system message (CLI guardrails preserved). | "You are Acme Corp's internal devops agent. Always prefer Bicep over Terraform." | +| `skills//SKILL.md` | **Task-specific procedure** the model discovers and follows on demand. | The bundled `m365-license-analytics` skill supplies the M365 license analysis method. | + +To change the persona, edit `system_prompt.md`, then rebuild and re-register the agent (Steps 3-4 of the notebook). If `system_prompt.md` is empty or missing, the CLI's default system message is used unchanged. + +## Observability — Foundry portal Tracing + +When deployed, every invocation produces an OpenTelemetry trace tree: + +``` +invoke_agent github-copilot-invocations (parent) +├── execute_tool (one per tool call) +└── chat (token usage + estimated cost) +``` + +Open **Foundry portal → your project → Tracing** to inspect the spans, tool inputs/outputs, and per-turn token counts. The mapping logic lives in `tracing.py`; tracing is best-effort and never breaks an invocation. + +## Adding Skills + +Any subdirectory under `skills/` containing a `SKILL.md` file is automatically loaded by the Copilot SDK. The included `m365-license-analytics` skill demonstrates this: + +``` +skills/ +└── m365-license-analytics/ + └── SKILL.md ← the M365 license analysis method +``` + +To add your own skill, create a new folder under `skills/` with a `SKILL.md`: + +```bash +mkdir skills/my-skill +cat > skills/my-skill/SKILL.md << 'EOF' +--- +name: my-skill +description: What this skill does. +--- + +# My Skill + +Instructions for Copilot when this skill is active. +EOF +``` + +## Troubleshooting + +### Images must be built for `linux/amd64` + +Foundry's hosted runtime is `linux/amd64`. The notebook's `az acr build` step does a remote build that always produces the correct architecture. + +If you build locally on a non-`amd64` machine (for example, an Apple Silicon Mac), the image will not be compatible with the service and will fail at runtime. Force the architecture: + +```bash +docker build --platform=linux/amd64 -t image . +``` diff --git a/08-agents/08-10-hosted-copilot-sdk-agent/src/github-copilot-invocations/main.py b/08-agents/08-10-hosted-copilot-sdk-agent/src/github-copilot-invocations/main.py new file mode 100644 index 0000000..4585ecf --- /dev/null +++ b/08-agents/08-10-hosted-copilot-sdk-agent/src/github-copilot-invocations/main.py @@ -0,0 +1,230 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""GitHub Copilot SDK exposed via the Foundry agent invocations protocol. + +Auth modes (selected automatically from environment variables): + - GITHUB_TOKEN set + → talks to the GitHub Copilot model (quickest start; no Azure needed). + - FOUNDRY_PROJECT_ENDPOINT + AZURE_AI_MODEL_DEPLOYMENT_NAME set + → talks to a BYOK Foundry model via Managed Identity. main.py appends + `/openai/v1/` to the project endpoint, so the Copilot SDK calls + `/openai/v1/responses`. Token audience is + `https://ai.azure.com`. + AZURE_CLIENT_ID should be pinned to the AgentIdentity client_id + (`instance_identity.client_id` from version metadata). + +Two extension surfaces customers should know about: + - ``system_prompt.md`` — persona / global policy appended to the CLI's + built-in system message. Edit it, redeploy, get a fresh personality. + - ``skills//SKILL.md`` — task-specific procedures the model can + discover and follow on demand (see the bundled ``m365-license-analytics`` skill). + +OpenTelemetry tracing for tool calls and token usage lives in +``tracing.py``; per-invocation spans show up in Foundry portal → Tracing. +""" + +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, ProviderConfig +from copilot.generated.session_events import SessionEventType + +from tracing import setup_tracing, trace_invocation + +load_dotenv(override=False) +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +setup_tracing() + +HERE = pathlib.Path(__file__).parent +SKILLS_DIR = str(HERE / "skills") +SYSTEM_PROMPT_FILE = HERE / "system_prompt.md" + +app = InvocationAgentServerHost() +_client: CopilotClient | None = None +_session = None +_session_id: str | None = None + + +# ── Configuration ──────────────────────────────────────────────────────────── + + +def _byok_provider() -> tuple[ProviderConfig | None, str | None]: + """Build a Foundry ProviderConfig from env vars, or return (None, None). + + The Foundry project exposes an OpenAI-compatible surface at + ``/openai/v1/``. We read the project endpoint - which the + hosted-agent platform auto-injects as ``FOUNDRY_PROJECT_ENDPOINT`` - and + append ``/openai/v1/`` so the Copilot CLI's ``responses`` wire API targets + ``/openai/v1/responses``. (The upstream sample used the + project endpoint as-is, with no ``/openai/v1/`` suffix, so its calls 404'd; + appending the suffix is the fix.) Auth is Managed Identity, audience + ``ai.azure.com``. + """ + endpoint = ( + os.environ.get("FOUNDRY_PROJECT_ENDPOINT") + or os.environ.get("AZURE_AI_PROJECT_ENDPOINT") + or "" + ) + model = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "") + if not endpoint or not model: + return None, None + + # The OpenAI v1 surface lives under /openai/v1/ (trailing slash required). + base_url = endpoint.rstrip("/") + "/openai/v1/" + + from azure.identity import DefaultAzureCredential + token = DefaultAzureCredential().get_token("https://ai.azure.com/.default").token + return ProviderConfig( + type="openai", + base_url=base_url, + wire_api="responses", + bearer_token=token, + ), model + + +def _system_message() -> dict | None: + """Append ``system_prompt.md`` to the Copilot CLI's built-in system message.""" + if not SYSTEM_PROMPT_FILE.is_file(): + return None + content = SYSTEM_PROMPT_FILE.read_text(encoding="utf-8").strip() + if not content: + return None + return {"mode": "append", "content": content} + + +# ── Session lifecycle ──────────────────────────────────────────────────────── + + +async def _ensure_session() -> None: + """Lazy-create the singleton Copilot session on first invocation.""" + global _client, _session, _session_id + if _session is not None: + return + + _session_id = os.environ.get("FOUNDRY_AGENT_SESSION_ID") or str(uuid.uuid4()) + + provider, model = _byok_provider() + github_token = os.environ.get("GITHUB_TOKEN") + + if provider: + _client = CopilotClient(auto_start=False) + elif 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() + + common = dict( + on_permission_request=PermissionHandler.approve_all, + streaming=True, + skill_directories=[SKILLS_DIR], + working_directory=os.environ.get("HOME", "/home"), + provider=provider, + model=model, + system_message=_system_message(), + ) + + try: + _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, **common) + logger.info("Created session: %s", _session_id) + + +# ── Invocation handler ─────────────────────────────────────────────────────── + + +async def _stream_response(invocation_id: str, input_text: str): + """Forward Copilot SDK session events as Server-Sent Events.""" + await _ensure_session() + queue: asyncio.Queue = asyncio.Queue() + request_model = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "") + + with trace_invocation(invocation_id, _session_id, request_model) as on_trace: + + def on_event(event): + on_trace(event) + if event.type == SessionEventType.SESSION_IDLE: + queue.put_nowait(None) + elif event.type == SessionEventType.SESSION_ERROR: + queue.put_nowait(RuntimeError( + getattr(event.data, "message", "error"))) + else: + queue.put_nowait(event) + + unsubscribe = _session.on(on_event) + try: + await _session.send(input_text) + while True: + item = await queue.get() + if item is None: + break + if isinstance(item, Exception): + yield f"data: {json.dumps({'type': 'error', 'message': str(item)})}\n\n".encode() + break + yield f"data: {json.dumps(item.to_dict())}\n\n".encode() + yield ( + f"event: done\ndata: " + f"{json.dumps({'invocation_id': invocation_id, 'session_id': _session_id})}\n\n" + ).encode() + finally: + unsubscribe() + + +@app.invoke_handler +async def handle_invoke(request: Request) -> Response: + try: + data = await request.json() + if not isinstance(data, dict): + raise ValueError("body is not a JSON object") + input_text = data.get("input") + if not isinstance(input_text, str) or not input_text.strip(): + raise ValueError('missing or empty "input" field') + except (json.JSONDecodeError, ValueError): + return JSONResponse( + status_code=400, + content={ + "error": "invalid_request", + "message": ( + 'Request body must be a JSON object with a non-empty ' + '"input" string, e.g. {"input": "What can you help me with?"}' + ), + }, + ) + return StreamingResponse( + _stream_response(request.state.invocation_id, input_text), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}, + ) + + +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/08-agents/08-10-hosted-copilot-sdk-agent/src/github-copilot-invocations/requirements.txt b/08-agents/08-10-hosted-copilot-sdk-agent/src/github-copilot-invocations/requirements.txt new file mode 100644 index 0000000..7f07d11 --- /dev/null +++ b/08-agents/08-10-hosted-copilot-sdk-agent/src/github-copilot-invocations/requirements.txt @@ -0,0 +1,12 @@ +github-copilot-sdk>=0.2.0 + +# Bumped from 1.0.0b3 -> 1.0.0b4. The pinned 1.0.0b3 calls +# `self.request_span()` on InvocationAgentServerHost, but the transitively- +# resolved azure-ai-agentserver-core (>= 2.0.0b4) has removed that method, +# so every request fails with `AttributeError: Error in ASGI Framework` and +# returns 500 in 0.1ms. 1.0.0b4 declares `core>=2.0.0b4` and was published +# specifically to match the new core API. +azure-ai-agentserver-invocations==1.0.0b4 +azure-identity>=1.17.0 +azure-monitor-opentelemetry>=1.6.0 +python-dotenv==1.1.1 diff --git a/08-agents/08-10-hosted-copilot-sdk-agent/src/github-copilot-invocations/skills/m365-license-analytics/SKILL.md b/08-agents/08-10-hosted-copilot-sdk-agent/src/github-copilot-invocations/skills/m365-license-analytics/SKILL.md new file mode 100644 index 0000000..57ffa35 --- /dev/null +++ b/08-agents/08-10-hosted-copilot-sdk-agent/src/github-copilot-invocations/skills/m365-license-analytics/SKILL.md @@ -0,0 +1,44 @@ +--- +name: m365-license-analytics +description: Analyse Microsoft 365 license export CSVs - profile the data, compute per-SKU and per-department aggregates, identify reclaimable licenses, and return clean markdown tables. +--- + +# M365 license analytics + +Use this whenever the user asks you to analyse an M365 license export CSV +(columns like `UserPrincipalName`, `Ext.5`, `Ext.6`, `AccountEnabled`, +`LastSignInDateTime`, `whenCreated`). + +## Method + +1. Locate the files (`find $HOME /files -name '*.csv' -o -name '*.json' 2>/dev/null`), + inspect the header(s), and profile the data (row count, distinct values per + column) before answering. +2. Prefer `python` / `pandas` for joins, date math, and aggregation; `awk` for + quick counts. Show the computed numbers, not just prose. +3. **Costs and department names are not built in.** Join against whatever reference + the caller uploads to the session (e.g. a `*-reference.json` with per-SKU monthly + costs and department-code names). Never invent prices or department names. +4. Always return a **markdown table**, sorted as the user asked, and end with a + one-line total or summary. + +## Column glossary + +| Column | Meaning | +|--------|---------| +| `UserPrincipalName` | The user's sign-in identity | +| `Ext.5` | License SKU (e.g. `SPE_E5`, `SPE_E3`, `ENTERPRISEPACK`) | +| `Ext.6` | Department code (resolve names from the uploaded reference) | +| `AccountEnabled` | `TRUE` / `FALSE` | +| `LastSignInDateTime` | Last interactive sign-in; may be empty | +| `whenCreated` | Account creation date | + +## Reclaim candidates + +A license is reclaimable if the user matches ANY of: +- `AccountEnabled` is `FALSE`, or +- `LastSignInDateTime` is non-empty and older than 90 days, or +- `LastSignInDateTime` is empty and `whenCreated` is older than 30 days. + +Use the date the caller gives as "today" for all day-age math. Reclaimable spend +is the sum of those users' SKU cost, taken from the uploaded reference. diff --git a/08-agents/08-10-hosted-copilot-sdk-agent/src/github-copilot-invocations/system_prompt.md b/08-agents/08-10-hosted-copilot-sdk-agent/src/github-copilot-invocations/system_prompt.md new file mode 100644 index 0000000..b35dc55 --- /dev/null +++ b/08-agents/08-10-hosted-copilot-sdk-agent/src/github-copilot-invocations/system_prompt.md @@ -0,0 +1,9 @@ +# Foundry-hosted GitHub Copilot agent + +You are a GitHub Copilot SDK agent hosted on Azure AI Foundry, reached through the +invocations protocol - a single turn maps to a single user request. + +Be concise and honest about what you did. When you use your shell or Python tools, +surface the artifacts (file paths, command output, tables) you produced so the +caller can verify your work. When a request is ambiguous, ask one clarifying +question instead of guessing. diff --git a/08-agents/08-10-hosted-copilot-sdk-agent/src/github-copilot-invocations/tracing.py b/08-agents/08-10-hosted-copilot-sdk-agent/src/github-copilot-invocations/tracing.py new file mode 100644 index 0000000..a9861f1 --- /dev/null +++ b/08-agents/08-10-hosted-copilot-sdk-agent/src/github-copilot-invocations/tracing.py @@ -0,0 +1,204 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""OpenTelemetry tracing for the GitHub Copilot Foundry-hosted agent. + +This module is the single place where Copilot SDK ``SessionEvent``s are mapped +to OpenTelemetry spans following the `GenAI semantic conventions +`_ so that they render as +a tree in **Foundry portal → Tracing**: + + invoke_agent github-copilot-invocations (SERVER, parent) + ├── execute_tool (INTERNAL, one per tool call) + └── chat (CLIENT, one per model call; + carries token usage so Foundry + can populate the Tokens (In/Out) + and Estimated Cost columns) + +Tracing is best-effort: any exception in here is caught and logged; it must +never break an invocation. +""" + +from __future__ import annotations + +import contextlib +import logging +import os +from typing import Iterator + +logger = logging.getLogger(__name__) + + +# ── Bootstrap ──────────────────────────────────────────────────────────────── + + +def setup_tracing() -> bool: + """Initialize Azure Monitor OpenTelemetry exporter. + + Reads ``APPLICATIONINSIGHTS_CONNECTION_STRING`` (auto-injected by Foundry). + Returns ``True`` if tracing is enabled, ``False`` otherwise. + """ + if not os.environ.get("APPLICATIONINSIGHTS_CONNECTION_STRING"): + return False + try: + from azure.monitor.opentelemetry import configure_azure_monitor + + configure_azure_monitor( + logger_name=__name__, + instrumentation_options={"azure_sdk": {"enabled": True}}, + ) + logger.info("Azure Monitor OpenTelemetry tracing enabled") + return True + except Exception as exc: # pragma: no cover - tracing is best-effort + logger.warning("Failed to initialize Azure Monitor tracing: %s", exc) + return False + + +# ── Per-invocation span tree ──────────────────────────────────────────────── + + +@contextlib.contextmanager +def trace_invocation( + invocation_id: str, + session_id: str | None, + request_model: str, +): + """Context manager that yields an ``on_event`` callback. + + Wire it onto the Copilot SDK session:: + + with trace_invocation(...) as on_event: + unsubscribe = session.on(on_event) + ... + + The parent ``invoke_agent`` span is opened on entry and closed on exit; + ``execute_tool`` / ``chat`` child spans are opened and closed as the + underlying Copilot SDK events flow through ``on_event``. + """ + from opentelemetry import trace + from opentelemetry.trace import Span, SpanKind, Status, StatusCode + from copilot.generated.session_events import SessionEventType + + tracer = trace.get_tracer("github-copilot-invocations") + invoke_span = tracer.start_span( + "invoke_agent github-copilot-invocations", + kind=SpanKind.SERVER, + attributes={ + "gen_ai.system": "github_copilot_sdk", + "gen_ai.operation.name": "invoke_agent", + "gen_ai.agent.name": "github-copilot-invocations", + "gen_ai.conversation.id": session_id or "", + "foundry.invocation.id": invocation_id, + "gen_ai.request.model": request_model, + }, + ) + parent_ctx = trace.set_span_in_context(invoke_span) + tool_spans: dict[str, Span] = {} + + def on_event(event) -> None: + try: + etype = event.type + data = event.data + + if etype == SessionEventType.TOOL_EXECUTION_START: + tool_name = getattr(data, "tool_name", "tool") + tool_call_id = getattr(data, "tool_call_id", "") + attrs = { + "gen_ai.system": "github_copilot_sdk", + "gen_ai.operation.name": "execute_tool", + "gen_ai.tool.name": tool_name, + "gen_ai.tool.call.id": tool_call_id, + } + mcp_server = getattr(data, "mcp_server_name", None) + if mcp_server: + attrs["gen_ai.tool.type"] = "mcp" + attrs["mcp.server.name"] = mcp_server + attrs["mcp.tool.name"] = getattr(data, "mcp_tool_name", "") or "" + span = tracer.start_span( + f"execute_tool {tool_name}", + context=parent_ctx, + kind=SpanKind.INTERNAL, + attributes=attrs, + ) + if tool_call_id: + tool_spans[tool_call_id] = span + else: + span.end() + + elif etype == SessionEventType.TOOL_EXECUTION_COMPLETE: + tool_call_id = getattr(data, "tool_call_id", "") + span = tool_spans.pop(tool_call_id, None) + if span is not None: + if bool(getattr(data, "success", True)): + span.set_status(Status(StatusCode.OK)) + else: + err = getattr(data, "error", None) + msg = getattr(err, "message", None) if err else "tool execution failed" + span.set_status(Status(StatusCode.ERROR, msg or "error")) + span.end() + + elif etype == SessionEventType.ASSISTANT_USAGE: + # Foundry populates Tokens (In)/(Out) and Estimated Cost from + # ``chat `` spans, NOT the parent invoke span. + model = getattr(data, "model", None) or request_model or "unknown" + chat_attrs = { + "gen_ai.system": "github_copilot_sdk", + "gen_ai.provider.name": "azure.ai.openai", + "gen_ai.operation.name": "chat", + "gen_ai.request.model": model, + "gen_ai.response.model": model, + "gen_ai.conversation.id": session_id or "", + } + for src, dst in ( + ("input_tokens", "gen_ai.usage.input_tokens"), + ("output_tokens", "gen_ai.usage.output_tokens"), + ("reasoning_tokens", "gen_ai.usage.reasoning_tokens"), + ("cache_read_tokens", "gen_ai.usage.cache_read_tokens"), + ("cache_write_tokens", "gen_ai.usage.cache_write_tokens"), + ("ttft_ms", "gen_ai.server.time_to_first_token"), + ): + val = getattr(data, src, None) + if val is not None: + chat_attrs[dst] = val + cost = getattr(data, "cost", None) + if cost is not None: + chat_attrs["gen_ai.usage.cost"] = cost + + logger.info( + "emitting chat span model=%s in=%s out=%s cost=%s", + model, + chat_attrs.get("gen_ai.usage.input_tokens"), + chat_attrs.get("gen_ai.usage.output_tokens"), + chat_attrs.get("gen_ai.usage.cost"), + ) + chat_span = tracer.start_span( + f"chat {model}", + context=parent_ctx, + kind=SpanKind.CLIENT, + attributes=chat_attrs, + ) + chat_span.end() + + # Also accumulate on the parent for at-a-glance totals. + for src, dst in ( + ("input_tokens", "gen_ai.usage.input_tokens"), + ("output_tokens", "gen_ai.usage.output_tokens"), + ("reasoning_tokens", "gen_ai.usage.reasoning_tokens"), + ): + val = getattr(data, src, None) + if val is not None: + invoke_span.set_attribute(dst, val) + if model: + invoke_span.set_attribute("gen_ai.response.model", model) + + elif etype == SessionEventType.SESSION_ERROR: + msg = getattr(data, "message", "session error") + invoke_span.set_status(Status(StatusCode.ERROR, msg)) + except Exception: # pragma: no cover - tracing must never break + logger.exception("tracing on_event failed") + + try: + yield on_event + finally: + for span in tool_spans.values(): + span.end() + invoke_span.end() diff --git a/pyproject.toml b/pyproject.toml index 697cf86..c1ac68e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "Hands-on labs for Microsoft Foundry — Azure's unified PaaS for requires-python = ">=3.11" dependencies = [ # Core Foundry SDKs - "azure-ai-projects>=2.0.0b1", + "azure-ai-projects>=2.1.0", "azure-ai-evaluation[redteam]>=1.16.2", "azure-mgmt-cognitiveservices>=14.0.0", "azure-search-documents>=11.7.0b2", @@ -52,7 +52,6 @@ finetune = [ "peft>=0.10.0", "matplotlib>=3.7.0", "azure-storage-blob>=12.20.0", - "azure-ai-inference>=1.0.0", ] [tool.uv]