-
Notifications
You must be signed in to change notification settings - Fork 25
Add agent framework example (and remove sample for chatprompt and mcp client) #386
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
66bc4a1
agent framework sample
MehakBindra 0466e3c
Merge branch 'main' into mehak/msft-ai-samples
MehakBindra 152750a
fixes
MehakBindra 69b590a
Merge branch 'mehak/msft-ai-samples' of https://github.com/microsoft/…
MehakBindra ae2a064
Merge branch 'main' into mehak/msft-ai-samples
MehakBindra ef23a8d
structured output
MehakBindra a9fe851
Merge branch 'mehak/msft-ai-samples' of https://github.com/microsoft/…
MehakBindra 70fc06a
fix citations
MehakBindra f8e0cd3
custom feedback
MehakBindra 6d8c87f
final fixes
MehakBindra 4d695b5
Merge branch 'main' into mehak/msft-ai-samples
MehakBindra 0205230
auggested actions
MehakBindra 35fe91a
move to app graph
MehakBindra db5a5b8
Merge branch 'main' into mehak/msft-ai-samples
MehakBindra File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
|
||
| ## 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. | ||
|
MehakBindra marked this conversation as resolved.
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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], | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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] |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.