diff --git a/README.md b/README.md index 0f797f7..1e00cbc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # M365 Inbox Agent for Azure Functions (Python) [![Python](https://img.shields.io/badge/Python-3.13-blue.svg)](https://www.python.org/downloads/) [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://github.com/codespaces/new?repo=Azure-Samples%2Fm365-inbox-serverless-agent-python) -An AI agent that triages your Microsoft 365 inbox: it escalates urgent mail to Teams, drafts replies, and emails you a daily briefing โ€” all following rules you write in plain markdown. +An AI agent that triages your Microsoft 365 inbox: it escalates urgent mail to Teams, drafts replies, and emails you a daily briefing - all following rules you write in plain markdown. **Powered by Azure Functions and Microsoft 365 connectors. You bring the markdown logic and a little Python for your rules.** @@ -9,10 +9,10 @@ Run it locally in minutes against sample data, point it at your real inbox, then ## What it does for you -- ๐Ÿšจ **Escalate** โ€” VIP / urgent mail is posted to your Teams channel, with an @mention. -- โœ‰๏ธ **Reply** โ€” action-required mail gets a grounded draft reply. -- ๐Ÿ“‹ **Brief** โ€” a daily summary of what matters lands in your inbox. -- ๐Ÿ’ฌ **Chat** โ€” ask read-only questions about your recent mail. +- ๐Ÿ“‹ **Daily Briefing**: a daily summary of what matters lands in your inbox. +- ๐Ÿšจ **Escalate**: VIP / urgent mail is posted to your Teams channel, with an @mention. +- โœ‰๏ธ **Reply**: action-required mail gets a grounded draft reply. +- ๐Ÿ’ฌ **Chat**: ask read-only questions about your recent mail. โ†’ Full walkthroughs in [docs/use-cases.md](docs/use-cases.md). @@ -21,7 +21,7 @@ Run it locally in minutes against sample data, point it at your real inbox, then - Python 3.13+. Easiest install: [uv](https://docs.astral.sh/uv/), then `uv python install 3.13`. - [Azure Functions Core Tools v4](https://learn.microsoft.com/en-us/azure/azure-functions/functions-run-local) โ‰ฅ 4.12.0 (the v5 preview is not yet compatible). - [Azure Developer CLI (`azd`)](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/). -- An Azure subscription. `azd provision` (Quickstart step 2) creates the Microsoft Foundry model deployment the agents need โ€” required even for the offline path. +- An Azure subscription. `azd provision` (Quickstart step 2) creates the Microsoft Foundry model deployment the agents need - required even for the offline path. - For real M365 (or `azd up`): permission to authorize Microsoft 365 connectors, plus the `connector-namespace` CLI extension: **macOS / Linux:** @@ -54,7 +54,7 @@ Use the [Core Tools v4 install guide](https://learn.microsoft.com/en-us/azure/az ### 2. Get the resources you need -`azd provision` creates the Foundry model deployment the agents require (needed even offline); `hydrate` copies the settings into `local.settings.json`. No API keys โ€” managed identity throughout. +`azd provision` creates the Foundry model deployment the agents require (needed even offline); `hydrate` copies the settings into `local.settings.json`. No API keys - managed identity throughout. **macOS / Linux:** ```bash @@ -68,7 +68,7 @@ azd provision .\infra\scripts\hydrate-local-settings.ps1 ``` -> Yes โ€” even the offline, sample-data run needs this one-time `azd provision`, because the agents call a Foundry model. It provisions the model deployment only; it never touches your inbox. +> Yes - even the offline, sample-data run needs this one-time `azd provision`, because the agents call a Foundry model. It provisions the model deployment only; it never touches your inbox. ### 3. Run the local client @@ -80,7 +80,7 @@ uv run python chat.py # terminal C (These commands work identically on macOS, Linux, and Windows.) -> ๐ŸชŸ **Windows local dev:** If `uv run func start` fails with `ModuleNotFoundError: No module named 'azure_functions_agents'`, the Microsoft Store python.exe alias on your `PATH` is shadowing the venv Python. See [Troubleshooting: Windows local dev](docs/troubleshooting.md#windows-local-dev) for the fix โ€” you'll need to remove the Store Python and disable App execution aliases in Windows Settings. +> ๐ŸชŸ **Windows local dev:** If `uv run func start` fails with `ModuleNotFoundError: No module named 'azure_functions_agents'`, the Microsoft Store python.exe alias on your `PATH` is shadowing the venv Python. See [Troubleshooting: Windows local dev](docs/troubleshooting.md#windows-local-dev) for the fix - you'll need to remove the Store Python and disable App execution aliases in Windows Settings. ### 4. Try it (offline, safe) @@ -88,25 +88,31 @@ Pick **1**, **2**, or **3**. The client shows a ๐ŸŸก Offline banner and runs eve Want it to act on your real inbox while still local? See [Go live with real M365](docs/configuration.md#go-live-with-real-m365). -### 5. Deploy to Azure, then try it again +### 5. Go live (set your recipient and Teams target) + +Set who real mail and Teams posts go to, **before** you deploy. Without these, the deployed agents stay in DRY RUN (the chat client's doctor banner will tell you). ```bash -azd up +azd env set MAILBOX_OWNER_EMAIL you@your-tenant.com # required for LIVE mail +azd env set TEAMS_TEAM_ID # optional, enables Teams alerts +azd env set TEAMS_CHANNEL_ID # optional, enables Teams alerts ``` -> **Known issue (point-in-time):** `azd up` may currently fail at the deploy step on Python 3.13 โ€” the Flex remote build uses Python 3.11.8 ([Azure/azure-dev#8538](https://github.com/Azure/azure-dev/issues/8538)). Simple workaround: [docs/deploy-python-313.md](docs/deploy-python-313.md). This note can be removed once the bug is fixed. +Get the Teams ids by opening the target channel in Teams โ†’ โ‹ฏ โ†’ **Get link to channel** (the URL contains both ids), or via [docs/configuration.md](docs/configuration.md). -Now `inbox-triage` fires automatically on every new email โ€” no client, no waiting. Send yourself a message, then watch your Teams channel (VIP / incident) or your inbox (replies). Tail the live trace with `azd monitor --logs`. +> Already deployed with placeholders? Set them now and re-run `azd up` (or just `azd provision`) to push the new values to the Function App. -### Clean up +### 6. Deploy to Azure, then try it again ```bash -azd down --purge +azd up ``` -> Hitting an error? See [docs/troubleshooting.md](docs/troubleshooting.md). +> **Known issue (point-in-time):** `azd up` may currently fail at the deploy step on Python 3.13 - the Flex remote build uses Python 3.11.8 ([Azure/azure-dev#8538](https://github.com/Azure/azure-dev/issues/8538)). Simple workaround: [docs/deploy-python-313.md](docs/deploy-python-313.md). This note can be removed once the bug is fixed. + +Now `inbox-triage` fires automatically on every new email - no client, no waiting. Send yourself a message, then watch your Teams channel (VIP / incident) or your inbox (replies). Tail the live trace with `azd monitor --logs`. -## Make it yours +### 7. Make it yours - โœ๏ธ Edit `skills/vip-rules.md` to set your VIPs, what to skip, and what escalates to Teams. - ๐Ÿ” The `weekly-rule-suggestions` agent proposes tuning that you approve by hand. @@ -114,9 +120,17 @@ azd down --purge โ†’ Full guide: [docs/customize.md](docs/customize.md). +### 8. Clean up + +```bash +azd down --purge +``` + +> Hitting an error? See [docs/troubleshooting.md](docs/troubleshooting.md). + ## How it works (the short version) -This is an Azure Functions app on the serverless agents runtime. Each agent is a markdown file (`*.agent.md`) that reasons over your rules in `skills/*.md`; a small `tools/match_rule.py` adds deterministic classification. Microsoft 365 actions go through Entra-authorized MCP connectors โ€” no app secrets, managed identity end to end. +This is an Azure Functions app on the serverless agents runtime. Each agent is a markdown file (`*.agent.md`) that reasons over your rules in `skills/*.md`; a small `tools/match_rule.py` adds deterministic classification. Microsoft 365 actions go through Entra-authorized MCP connectors - no app secrets, managed identity end to end. โ†’ Deeper dives: [How it works](docs/how-it-works.md) ยท [Configuration & deployment](docs/configuration.md) ยท [Customize](docs/customize.md) ยท [Troubleshooting & reference](docs/troubleshooting.md) diff --git a/azure.yaml b/azure.yaml index 09429a1..e427c5c 100644 --- a/azure.yaml +++ b/azure.yaml @@ -33,11 +33,11 @@ hooks: postdeploy: posix: shell: sh - run: ./infra/scripts/configure-trigger.sh + run: ./infra/scripts/hydrate-local-settings.sh && ./infra/scripts/configure-trigger.sh interactive: true continueOnError: false windows: shell: pwsh - run: ./infra/scripts/configure-trigger.ps1 + run: ./infra/scripts/hydrate-local-settings.ps1; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; ./infra/scripts/configure-trigger.ps1 interactive: true continueOnError: false diff --git a/chat.py b/chat.py index ca67757..5e6cb3b 100644 --- a/chat.py +++ b/chat.py @@ -125,6 +125,43 @@ def chat_url(agent_name: str) -> str: return url +def _host_kind() -> str: + """'cloud' if BASE_URL points to *.azurewebsites.net, else 'local'.""" + return "cloud" if "azurewebsites.net" in BASE_URL else "local" + + +def _host_reachable(timeout: float = 2.0) -> bool: + """True if the Functions host at BASE_URL responds at all (any HTTP code). + + Connection refused / DNS failure / TLS error => host is not reachable. + """ + probe = f"{BASE_URL}/admin/host/status" + try: + urllib.request.urlopen(probe, timeout=timeout) + return True + except urllib.error.HTTPError: + return True + except Exception: + return False + + +def _print_host_unreachable_hint() -> None: + """Shared 'host is down' guidance โ€” pointed at local and cloud paths.""" + if _host_kind() == "cloud": + print(" AGENT_URL points to the cloud Function App but it did not respond.") + print(" Re-run: `source ./infra/scripts/use-cloud-host.sh` (refreshes the key),") + print(" or check the Function App is running in the Azure portal.") + return + print(" The local Functions host is not running. Either:") + print(" A) Run it locally (3 terminals):") + print(" terminal A: azurite --silent --location ~/.azurite") + print(" terminal B: uv run func start") + print(" terminal C: uv run python chat.py") + print(" B) Or call the deployed cloud Function App from this shell:") + print(" source ./infra/scripts/use-cloud-host.sh") + print(" uv run python chat.py") + + _TEAMS_TRIGGERING_RX = re.compile(r"urgent|p1\b|incident|escalat|outage", re.IGNORECASE) _SKIP_RX = re.compile(r"^\s*fyi\b|newsletter", re.IGNORECASE) @@ -632,7 +669,8 @@ def trigger_agent(agent_name: str, mode_icon: str, mode_label: str = "") -> None return except Exception as exc: print(f"\nError calling {agent_name} /chat: {exc}") - print("Is the Functions host running with `uv run func start`?\n") + _print_host_unreachable_hint() + print() return elapsed = time.monotonic() - start @@ -652,9 +690,16 @@ def mark(key: str) -> str: print("\nConfig readiness") print("================") + kind = _host_kind() + up = _host_reachable() + host_label = f"{'๐ŸŸข' if up else '๐Ÿ”ด'} {kind} ({BASE_URL}) {'reachable' if up else 'NOT reachable'}" + print(f" {'Functions host':<22} {host_label}") for key in ("OUTLOOK_MCP_ENDPOINT", "MAILBOX_OWNER_EMAIL", "TEAMS_TEAM_ID", "TEAMS_CHANNEL_ID"): print(f" {key:<22} {mark(key)}") print() + if not up: + _print_host_unreachable_hint() + print() print(f" {'Agent':<26} {'Mode':<9} {'Sends email':<13} Posts Teams") print(f" {'-' * 26} {'-' * 9} {'-' * 13} {'-' * 11}") for label, name in ( @@ -883,7 +928,8 @@ def chat_with_inbox() -> None: continue except Exception as exc: print(f"\n Error: {exc}") - print(" Is the Functions host running with `uv run func start`?\n") + _print_host_unreachable_hint() + print() continue response_text = (result.get("response") or "").strip() diff --git a/infra/scripts/hydrate-local-settings.ps1 b/infra/scripts/hydrate-local-settings.ps1 index b5a5c11..a03d5fb 100644 --- a/infra/scripts/hydrate-local-settings.ps1 +++ b/infra/scripts/hydrate-local-settings.ps1 @@ -78,4 +78,8 @@ $settings = [ordered]@{ $settings | ConvertTo-Json -Depth 5 | Set-Content -Path local.settings.json -Encoding UTF8 Write-Host "Wrote local.settings.json -- provider=foundry, model=$($envVars['FOUNDRY_MODEL'])" -Write-Host "Run 'func start' to start the host." +Write-Host "" +Write-Host "Next: run these in three separate terminals from the project root:" +Write-Host " azurite --silent --skipApiVersionCheck --location .azurite # terminal A" +Write-Host " uv run func start # terminal B" +Write-Host " uv run python chat.py # terminal C" diff --git a/infra/scripts/hydrate-local-settings.sh b/infra/scripts/hydrate-local-settings.sh index 13a9326..0660e7c 100755 --- a/infra/scripts/hydrate-local-settings.sh +++ b/infra/scripts/hydrate-local-settings.sh @@ -42,4 +42,8 @@ EOF chmod 600 local.settings.json echo "Wrote local.settings.json โ€” provider=foundry, model=${FOUNDRY_MODEL}" -echo "Run 'func start' to start the host." +echo "" +echo "Next: run these in three separate terminals from the project root:" +echo " azurite --silent --skipApiVersionCheck --location .azurite # terminal A" +echo " uv run func start # terminal B" +echo " uv run python chat.py # terminal C" diff --git a/infra/scripts/use-cloud-host.ps1 b/infra/scripts/use-cloud-host.ps1 new file mode 100644 index 0000000..bf0494d --- /dev/null +++ b/infra/scripts/use-cloud-host.ps1 @@ -0,0 +1,24 @@ +# Dot-source (do not run) this file: +# . ./infra/scripts/use-cloud-host.ps1 +# It sets $env:AGENT_URL + $env:FUNCTION_KEY so `uv run python chat.py` calls +# the deployed Function App in Azure instead of the local Functions host +# (skips needing `azurite` + `uv run func start` in two extra terminals). + +$ErrorActionPreference = "Stop" + +$uri = azd env get-value SERVICE_API_URI 2>$null +if (-not $uri) { Write-Error "no azd env โ€” run 'azd up' first"; return } +$rg = azd env get-value RESOURCE_GROUP 2>$null +if (-not $rg) { Write-Error "RESOURCE_GROUP not in azd env"; return } +$app = azd env get-value AZURE_FUNCTION_APP_NAME 2>$null +if (-not $app) { Write-Error "AZURE_FUNCTION_APP_NAME not in azd env"; return } + +$key = az functionapp keys list -g $rg -n $app --query functionKeys.default -o tsv 2>$null +if (-not $key) { Write-Error "could not fetch function key โ€” try 'az login'"; return } + +$env:AGENT_URL = $uri +$env:FUNCTION_KEY = $key +Write-Host "AGENT_URL=$uri" +$tail = $key.Substring([Math]::Max(0, $key.Length - 4)) +Write-Host "FUNCTION_KEY=***$tail" +Write-Host "chat.py will now call the deployed Function App ($app)." diff --git a/infra/scripts/use-cloud-host.sh b/infra/scripts/use-cloud-host.sh new file mode 100755 index 0000000..da26ff0 --- /dev/null +++ b/infra/scripts/use-cloud-host.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Source (do not execute) this file: +# source ./infra/scripts/use-cloud-host.sh +# It exports AGENT_URL + FUNCTION_KEY so `uv run python chat.py` calls the +# deployed Function App in Azure instead of the local Functions host +# (skips needing `azurite` + `uv run func start` in two extra terminals). + +_die() { echo "use-cloud-host: $*" >&2; return 1 2>/dev/null || exit 1; } + +command -v azd >/dev/null || _die "azd not on PATH" || return 1 +command -v az >/dev/null || _die "az not on PATH" || return 1 + +_uri=$(azd env get-value SERVICE_API_URI 2>/dev/null) || _die "no azd env โ€” run 'azd up' first" || return 1 +_rg=$(azd env get-value RESOURCE_GROUP 2>/dev/null) || _die "RESOURCE_GROUP not in azd env" || return 1 +_app=$(azd env get-value AZURE_FUNCTION_APP_NAME 2>/dev/null) || _die "AZURE_FUNCTION_APP_NAME not in azd env" || return 1 + +_key=$(az functionapp keys list -g "$_rg" -n "$_app" --query functionKeys.default -o tsv 2>/dev/null) \ + || _die "could not fetch function key โ€” try 'az login'" || return 1 +[ -n "$_key" ] || _die "function key empty โ€” host may still be starting" || return 1 + +export AGENT_URL="$_uri" +export FUNCTION_KEY="$_key" +echo "AGENT_URL=$AGENT_URL" +echo "FUNCTION_KEY=***${_key: -4}" +echo "chat.py will now call the deployed Function App ($_app)." +unset _uri _rg _app _key