Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions examples/ai-agentframework/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
> [!CAUTION]
> This project is in public preview. We'll do our best to maintain compatibility, but there may be breaking changes in upcoming releases.

# Teams AI Agent (agent-framework)

A Teams bot powered by [agent-framework](https://github.com/microsoft/agent-framework) and Azure OpenAI. Supports streaming responses, inline citations from MCP search results, per-conversation memory, and Microsoft Graph-backed local tools alongside remote MCP servers.

## Features

- **Streaming responses** — text streams token-by-token into Teams as the model generates it
- **Citations** — sources from MCP search tools are attached as clickable references in the reply
- **Conversation memory** — each conversation maintains its own session so the agent remembers context across turns
- **AI-generated label + feedback** — replies include the Teams "AI-generated" label and thumbs up/down feedback buttons; clicking a reaction opens a custom Adaptive Card form for additional feedback
- **Local tools** — Microsoft Graph-backed tools for org directory lookups, org hierarchy, team membership, and presence
- **MCP tools** — remote tool servers: Microsoft Learn docs search

Comment thread
MehakBindra marked this conversation as resolved.
## Prerequisites

- Python >= 3.12, < 3.15
- UV >= 0.8.11
- An Azure OpenAI resource with a deployed model
- A Teams bot registration (App ID + password)

## Setup

Create a `.env` file in `examples/ai-agentframework/`:

```env
# Azure OpenAI
AZURE_OPENAI_ENDPOINT=https://<your-resource>.openai.azure.com
AZURE_OPENAI_MODEL=<deployment-name>
AZURE_OPENAI_API_KEY=<api-key>

# Teams bot credentials — also used by Microsoft Graph local tools
CLIENT_ID=<app-id>
TENANT_ID=<tenant-id>
CLIENT_SECRET=<client-secret>
```

`AZURE_OPENAI_MODEL` is the **deployment name** of your model, not the base model name. The bot's Service Principal (`CLIENT_ID`) is used for Teams and Microsoft Graph.

### Microsoft Graph permissions

The local tools call Graph as the bot's service principal (app-only). Grant these **Application** permissions to the app registration in the Azure portal (**Entra ID > App registrations > your app > API permissions**), then click **Grant admin consent**:

| Permission | Used by |
| ---------------------- | -------------------------------------- |
| `User.Read.All` | `find_people`, `get_org_context` |
| `Group.Read.All` | `list_team_members` |
| `GroupMember.Read.All` | `list_team_members` |
| `Presence.Read.All` | `get_presence` |

### Using a Service Principal for Azure OpenAI instead of an API key

`agent.py` authenticates to Azure OpenAI with `AZURE_OPENAI_API_KEY`. If you'd rather use the bot's Service Principal (so the same identity powers Teams, Graph, and Azure OpenAI), swap `api_key` for a `ClientSecretCredential`:

```python
from azure.identity import ClientSecretCredential

client = OpenAIChatClient(
model=getenv("AZURE_OPENAI_MODEL"),
azure_endpoint=getenv("AZURE_OPENAI_ENDPOINT"),
credential=ClientSecretCredential(
tenant_id=getenv("TENANT_ID"),
client_id=getenv("CLIENT_ID"),
client_secret=getenv("CLIENT_SECRET"),
),
)
```

Then drop `AZURE_OPENAI_API_KEY` from `.env` and grant the Service Principal the **Azure AI User** role on the Azure OpenAI resource.

### Teams bot registration

Follow the standard Teams bot setup to get a bot App ID and password, and configure the messaging endpoint to point at this bot (e.g. via [Dev Tunnels](https://learn.microsoft.com/azure/developer/dev-tunnels/overview) for local development).

## Running

```bash
cd examples/ai-agentframework
uv run src/main.py
```

## Example interactions

Once the bot is running in a Teams chat, try:

- `Who is <colleague's name>?` — directory lookup via `find_people`
- `Who does <colleague> report to?` — manager + direct reports via `get_org_context`
- `Who's on the <team-name> team?` — group membership via `list_team_members`
- `Is <colleague> available right now?` — Teams presence via `get_presence`
- `Find the manager of <colleague> and tell me if they're online` — chains `get_org_context` and `get_presence`
- `How do I send a proactive message in teams.py?` — searches Microsoft Learn docs (MCP)

## Architecture

```
main.py — Teams App, message handler, streaming, citations
agent.py — Agent setup, OpenAIChatClient, AgentMiddleware
local_tools.py — @tool functions (Microsoft Graph-backed directory lookups)
mcp_tools.py — MCP server declarations (remote tool servers)
```

`main.py` streams every response with `agent.run(..., stream=True)`. Citations collected during tool calls are attached to the final activity.

`AgentMiddleware` intercepts every tool call to log it and extract citation URLs from MCP search results. Citations are filtered to only those the model actually referenced with `[N]` markers before being attached to the Teams reply.
Comment thread
MehakBindra marked this conversation as resolved.
15 changes: 15 additions & 0 deletions examples/ai-agentframework/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[project]
name = "ai-agentframework"
version = "0.1.0"
description = "Microsoft Teams bot using the agent-framework library"
readme = "README.md"
requires-python = ">=3.12,<3.15"
dependencies = [
"dotenv>=0.9.9",
"microsoft-teams-apps",
"agent-framework",
"msgraph-sdk>=1.0.0",
]

[tool.uv.sources]
microsoft-teams-apps = { workspace = true }
95 changes: 95 additions & 0 deletions examples/ai-agentframework/src/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the MIT License.
"""

import json
import logging
from collections.abc import Awaitable, Callable
from os import getenv
from typing import Any, cast

from agent_framework import Agent, FunctionInvocationContext, FunctionMiddleware
from agent_framework.openai import OpenAIChatClient
from dotenv import find_dotenv, load_dotenv
from local_tools import tools as local_tools
from mcp_tools import mcp_tools

load_dotenv(find_dotenv(usecwd=True))

logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)


class AgentMiddleware(FunctionMiddleware):
"""Logs every tool call and extracts MCP citations from results.

citations is reset at the start of each message turn and populated as tools run.
Only URLs from results matching { results: [{ contentUrl, title, content }] } are collected.
"""

citations: dict[str, Any]

async def process(self, context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]]) -> None:
logger.info("tool call: %s(%s)", context.function.name, context.arguments)
await call_next()
result: Any = context.result
if isinstance(result, list):
blocks = cast("list[Any]", result)
result = " ".join(str(c.text) for c in blocks if getattr(c, "text", None))
logger.info("tool result: %s -> %s", context.function.name, result)

try:
parsed = json.loads(result)
except (json.JSONDecodeError, TypeError) as e:
logger.debug("citation extraction skipped for %s: %s", context.function.name, e)
return
if not isinstance(parsed, dict):
return
parsed = cast("dict[str, Any]", parsed)

for item in cast("list[dict[str, Any]]", parsed.get("results", [])):
url = item.get("contentUrl") or item.get("link")
if not url:
continue
entry = self.citations.setdefault(
url,
{
"position": len(self.citations) + 1,
"url": url,
"title": item.get("title") or "",
"snippet": (item.get("content") or item.get("description") or "")[:160],
},
)
item["citation"] = f"[{entry['position']}]"
context.result = json.dumps(parsed)


def _require_env(name: str) -> str:
value = getenv(name)
if not value:
raise ValueError(f"Required environment variable {name!r} is not set.")
return value


client = OpenAIChatClient(
model=_require_env("AZURE_OPENAI_MODEL"),
azure_endpoint=_require_env("AZURE_OPENAI_ENDPOINT"),
api_key=_require_env("AZURE_OPENAI_API_KEY"),
)

INSTRUCTIONS = """\
You are a helpful Teams assistant with access to local tools and remote MCP servers.

When you use information from a search tool, cite your sources inline using the "citation" value \
provided in each result (e.g. [1], [2]).
Do not add a references or sources list at the end of your response — citations are displayed separately in the UI.
"""

tool_logger = AgentMiddleware()
agent = Agent(
client=client,
instructions=INSTRUCTIONS,
tools=[*local_tools, *mcp_tools],
middleware=[tool_logger],
)
143 changes: 143 additions & 0 deletions examples/ai-agentframework/src/local_tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the MIT License.
"""

import asyncio
from typing import Annotated

from agent_framework import tool
from microsoft_teams.apps import App
from msgraph.generated.groups.groups_request_builder import ( # pyright: ignore[reportMissingTypeStubs]
GroupsRequestBuilder, # pyright: ignore[reportMissingTypeStubs]
)
from msgraph.generated.users.users_request_builder import UsersRequestBuilder # pyright: ignore[reportMissingTypeStubs]
from msgraph.graph_service_client import GraphServiceClient
from pydantic import Field

_graph: GraphServiceClient | None = None


def bind_app(app: App) -> None:
"""Wire the App's Graph client into the tools. Call once after App() construction."""
global _graph
_graph = app.get_app_graph()


def _require_graph() -> GraphServiceClient:
if _graph is None:
raise RuntimeError("local_tools.bind_app(app) must be called before invoking tools")
return _graph


def _person(user: object) -> dict[str, str]:
return {
"id": getattr(user, "id", None) or "",
"name": getattr(user, "display_name", None) or "",
"upn": getattr(user, "user_principal_name", None) or "",
"email": getattr(user, "mail", None) or getattr(user, "user_principal_name", None) or "",
"title": getattr(user, "job_title", None) or "",
"department": getattr(user, "department", None) or "",
"office": getattr(user, "office_location", None) or "",
}


@tool
async def find_people(
query: Annotated[str, Field(description="Name, email, job title, or department fragment")],
limit: Annotated[int, Field(description="Max results", ge=1, le=25)] = 5,
) -> list[dict[str, str]] | str:
"""Search the org directory. Returns up to `limit` people with name, email, title, department, office."""
safe = query.replace('"', "")
params = UsersRequestBuilder.UsersRequestBuilderGetQueryParameters(
search=f'"displayName:{safe}" OR "mail:{safe}" OR "jobTitle:{safe}" OR "department:{safe}"',
select=["id", "displayName", "mail", "userPrincipalName", "jobTitle", "department", "officeLocation"],
top=limit,
)
config = UsersRequestBuilder.UsersRequestBuilderGetRequestConfiguration(query_parameters=params)
config.headers.add("ConsistencyLevel", "eventual")
result = await _require_graph().users.get(request_configuration=config)
if not result or not result.value:
return f"No people found matching {query!r}."
return [_person(u) for u in result.value]


@tool
async def get_org_context(
user: Annotated[
str,
Field(description="User's Graph id (preferred), UPN, or email. Prefer the id from find_people results."),
],
) -> dict[str, object] | str:
"""Get a person's profile, their manager, and their direct reports in one call."""
user_item = _require_graph().users.by_user_id(user)
profile, manager, reports = await asyncio.gather(
user_item.get(),
user_item.manager.get(),
user_item.direct_reports.get(),
return_exceptions=True,
)

if isinstance(profile, BaseException) or not profile:
return f"Could not get profile for {user!r}: {profile}"

return {
"profile": _person(profile),
"manager": _person(manager) if manager and not isinstance(manager, BaseException) else None,
"direct_reports": (
[_person(u) for u in reports.value] # type: ignore
if reports and not isinstance(reports, BaseException) and getattr(reports, "value", None)
else []
),
}


@tool
async def list_team_members(
team_or_group_name: Annotated[str, Field(description="Display name of a Team or M365 group")],
limit: Annotated[int, Field(description="Max members to return", ge=1, le=50)] = 20,
) -> list[dict[str, str]] | str:
"""Resolve a Team/M365 group by display name and return its members."""
safe = team_or_group_name.replace("'", "''")
group_params = GroupsRequestBuilder.GroupsRequestBuilderGetQueryParameters(
filter=f"displayName eq '{safe}'",
select=["id", "displayName"],
top=1,
)
group_config = GroupsRequestBuilder.GroupsRequestBuilderGetRequestConfiguration(query_parameters=group_params)
groups = await _require_graph().groups.get(request_configuration=group_config)
if not groups or not groups.value:
return f"No group found with display name {team_or_group_name!r}."

group_id = groups.value[0].id
if not group_id:
return f"Group {team_or_group_name!r} has no id."

members = await _require_graph().groups.by_group_id(group_id).members.get()
if not members or not members.value:
return f"Group {team_or_group_name!r} has no members."

return [_person(m) for m in members.value[:limit]]


@tool
async def get_presence(
user: Annotated[
str,
Field(description="User's Graph id (preferred), UPN, or email. Prefer the id from find_people results."),
],
) -> dict[str, str] | str:
"""Get a person's current Teams presence (availability + activity)."""
try:
presence = await _require_graph().users.by_user_id(user).presence.get()
except Exception as e:
return f"Could not get presence for {user!r}: {e}"
if not presence:
return f"No presence information for {user!r}."
return {
"availability": presence.availability or "Unknown",
"activity": presence.activity or "Unknown",
}


tools = [find_people, get_org_context, list_team_members, get_presence]
Loading
Loading