Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7d5b445
Support suggested actions on streamed messages (#390)
heyitsaamir Apr 17, 2026
1c630ee
build(deps): bump authlib from 1.6.9 to 1.6.11 (#394)
dependabot[bot] Apr 17, 2026
8e0cdfa
Add proactive threading support (#389)
corinagum Apr 20, 2026
10c1842
Remove supports_threading
corinagum Apr 20, 2026
c783a7c
Revert "Remove supports_threading"
corinagum Apr 20, 2026
fd88c42
Reapply "Remove supports_threading"
corinagum Apr 20, 2026
a79a683
Wording fixes
corinagum Apr 20, 2026
f84ccfd
Security hardening: token validation and service URL improvements (#370)
corinagum Apr 21, 2026
67c55b2
Add agent framework example (and remove sample for chatprompt and mcp…
MehakBindra Apr 21, 2026
2da18bd
build(deps): bump python-dotenv from 1.1.1 to 1.2.2 (#398)
dependabot[bot] Apr 21, 2026
35df5b1
Local tool simplification (#399)
MehakBindra Apr 23, 2026
35f9fa1
fix agent-framework pyright issue and added examples to CI (#400)
lilyydu Apr 23, 2026
2192aad
remove devtools (#381)
lilyydu Apr 23, 2026
01228e6
Fix allowlist plumbing through through TokenValidator (#404)
corinagum Apr 24, 2026
cda875a
Add proactive message update example (#405)
singhk97 Apr 24, 2026
433699a
Sample : a2a-in-teams (#401)
MehakBindra Apr 25, 2026
7b94be3
build(deps): bump postcss from 8.5.6 to 8.5.10 in /examples/tab/Web (…
dependabot[bot] Apr 25, 2026
28c7439
Deprecate ai, openai, mcpplugin, and a2a packages (#406)
heyitsaamir Apr 25, 2026
09055bb
Restore and deprecate devtools package (#410)
heyitsaamir Apr 25, 2026
0dc87ef
Add devtools to uv.lock (#411)
heyitsaamir Apr 25, 2026
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
2 changes: 1 addition & 1 deletion .azdo/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ extends:
# Always use internal feed for dependency resolution (avoids firewall issues on 1ES pool)
$env:UV_INDEX_URL = "https://build:$($env:SYSTEM_ACCESSTOKEN)@pkgs.dev.azure.com/DomoreexpGithub/Github_Pipelines/_packaging/TeamsSDKPreviews/pypi/simple/"
Write-Host "Using authenticated Azure Artifacts feed"
uv pip install -e packages/common -e packages/api -e packages/cards -e packages/apps -e packages/botbuilder -e packages/graph -e packages/ai -e packages/openai -e packages/mcpplugin -e packages/a2aprotocol -e packages/devtools
uv pip install -e packages/common -e packages/api -e packages/cards -e packages/apps -e packages/botbuilder -e packages/devtools -e packages/graph -e packages/ai -e packages/openai -e packages/mcpplugin -e packages/a2aprotocol
env:
SYSTEM_ACCESSTOKEN: $(System.AccessToken)
target:
Expand Down
9 changes: 2 additions & 7 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,12 @@ Microsoft Teams SDK for Python is a comprehensive SDK for building Microsoft Tea
### Basic Teams App Validation
1. Navigate to test app: `cd examples/echo`
2. Start the app: `python src/main.py`
3. **Expected output**: App starts on ports 3978 and 3979 with logs:
3. **Expected output**: App starts on port 3978 with logs:
```
[INFO] Teams app started successfully
[INFO] @teams/app.DevToolsPlugin listening on port 3979 🚀
```
4. **Test endpoints**:
- Health check: `curl http://localhost:3978/` (returns `{"status":"healthy","port":3978}`)
- DevTools UI: `curl http://localhost:3979/devtools` (returns HTML page)
5. Stop with Ctrl+C

### Required Pre-commit Validation
Expand All @@ -101,7 +99,6 @@ pyright # Type checking validation
- **microsoft-teams-api**: Teams API client
- **microsoft-teams-cards**: Adaptive cards support
- **microsoft-teams-common**: Shared utilities
- **microsoft-teams-devtools**: Development and debugging tools
- **microsoft-teams-graph**: Microsoft Graph integration
- **microsoft-teams-openai**: OpenAI integration
- **microsoft-teams-mcpplugin**: MCP protocol integration
Expand All @@ -126,13 +123,11 @@ Available test apps for development and validation:
1. **Run commands with UV** (recommended): Use `uv run pytest packages/[package-name]` or **activate virtual environment**: `source .venv/bin/activate`
2. **Run affected tests**: `pytest packages/[package-name]` for specific package (or `uv run pytest packages/[package-name]`)
3. **Validate with test app**: Use `examples/echo` for basic functionality validation (starts a blocking server process)
4. **Check DevTools web app**: Access http://localhost:3979/devtools when app is running

### Debugging and Development
- **DevTools Web App**: Available at port 3979 when running any test app
- **Logging**: Apps provide structured logging for debugging
- **Hot reload**: No hot reload - restart apps after changes
- **Port conflicts**: Default ports are 3978 (main) and 3979 (devtools)
- **Port conflicts**: Default port is 3978 (main)

### CI/CD Integration
The CI pipeline (`.github/workflows/ci.yml`) runs:
Expand Down
1 change: 0 additions & 1 deletion .github/scripts/analyze_issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@
- cards: Adaptive cards
- ai: AI/function calling utilities
- botbuilder: Bot Framework integration plugin
- devtools: Development tools plugin
- mcpplugin: MCP server plugin
- a2aprotocol: A2A protocol plugin
- graph: Microsoft Graph integration
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ on:
pull_request:
paths:
- packages/**
- examples/**
push:
branches: ["main"]
paths:
- packages/**
- examples/**

# Declare default permissions as read only.
permissions: read-all
Expand Down
1 change: 0 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ All packages live in `packages/`, each with `src/microsoft_teams/<package>/` lay
| `cards` | Adaptive cards |
| `ai` | AI/function calling utilities |
| `botbuilder` | Bot Framework integration plugin |
| `devtools` | Development tools plugin |
| `mcpplugin` | MCP server plugin |
| `a2aprotocol` | A2A protocol plugin |
| `graph` | Microsoft Graph integration |
Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ A comprehensive SDK for building Microsoft Teams applications, bots, and AI agen
- [`microsoft-teams-api`](./packages/api/README.md)
- [`microsoft-teams-cards`](./packages/cards/README.md)
- [`microsoft-teams-common`](./packages/common/README.md)
- [`microsoft-teams-devtools`](./packages/devtools/README.md)
- [`microsoft-teams-graph`](./packages/graph/README.md)
- [`microsoft-teams-openai`](./packages/openai/README.md)
- [`microsoft-teams-botbuilder`](./packages/botbuilder/README.md)
Expand Down
2 changes: 1 addition & 1 deletion RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ The [publish pipeline](https://dev.azure.com/DomoreexpGithub/Github_Pipelines/_b
- **Public** — signs packages via ESRP and publishes to PyPI. Requires approval via the `teams-sdk-publish` ADO environment before the ESRP release proceeds.
5. Pipeline runs: Build > Test > Publish

> **Note:** The `devtools` package is excluded from publishing. The pipeline filters out packages matching the `ExcludePackageFolders` variable. Prerelease versions are tagged `next` on PyPI; stable versions are tagged `latest`.
> **Note:** The pipeline filters out packages matching the `ExcludePackageFolders` variable. Prerelease versions are tagged `next` on PyPI; stable versions are tagged `latest`.

#### Installing Published Packages

Expand Down
123 changes: 113 additions & 10 deletions examples/a2a-test/README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,117 @@
# Sample: A2A Client and Server
a sample showcasing an a2a server / client
# Sample: Two Teams bots relaying questions via A2A + Adaptive Cards

Two symmetric Teams bots, Alice and Bob, each backed by an LLM agent. The user DMs one of them; the LLM decides whether to answer directly or forward the question to the other bot over the A2A protocol.

Open up devtools for the client, and send a message:
This sample demonstrates:

- **LLM-driven peer routing** — each bot's agent reads the other's A2A `AgentCard.description` (fetched lazily via `A2ACardResolver`) and uses that to decide whether to forward.
- **Human-in-the-loop via Adaptive Cards** — when a peer asks, the answering bot pushes an ask-card to its human operator's 1:1; the operator types a reply and submits.
- **Async reply, folded back into chat** — the answer comes back over A2A and is delivered both as a reply card and as a `[peer update]` note injected into the user's LLM session, so the next turn's model sees it as context.

## Flow

```mermaid
sequenceDiagram
actor UA as User-A
participant A as Alice (LLM agent)
participant B as Bob (LLM agent)
actor OB as Operator-B

UA->>A: "how do I scale my postgres database?"
Note over A: LLM reads Bob's AgentCard ("backend & infra"), picks tool:<br/>send_to_peer("bob", "how do I scale my postgres database?")<br/>stash awaiting_reply[qid] = User-A conv
A->>B: A2A ask {qid, question, sender, reply_url=Alice}
A-->>UA: streamed reply ("Asked Bob, will let you know…")
Note over B: validate reply_url ∈ allowlist<br/>stash inbound_asks[qid] = {reply_url, sender, question}
B->>OB: push ask card
OB->>B: submit "use read replicas + pgbouncer" (carries qid)
Note over B: pop inbound_asks[qid] → trusted reply_url
B->>A: A2A reply {qid, answer, responder}
Note over A: pop awaiting_reply[qid]<br/>inject "[peer update] Bob replied: 'use read replicas + pgbouncer'…" into User-A's session
A->>UA: push reply card
```

## Files

**Entry points** — start here.
- `src/bot_a.py` — Alice. Teams `/api/messages` and A2A `/a2a` share port **3978**. Edit the `DESCRIPTION` constant to set Alice's expertise; this becomes her A2A AgentCard description that Bob's LLM reads to decide when to forward.
- `src/bot_b.py` — Bob. Same layout on port **3979**. Same `DESCRIPTION` knob for Bob.

**LLM agent**
- `src/agent.py` — `BotAgent` builds the `agent_framework` `Agent`, lazily fetches peer A2A cards via `A2ACardResolver`, and exposes `get_agent()`, `session_for(conv_id)`, and `record_peer_reply(...)` for the bot file to use.

**A2A layer**
- `src/a2a_executor.py` — A2A server dispatch: `ask` → validate `reply_url`, stash, push card to operator; `reply` → push card to the original user and call `on_peer_reply`.
- `src/a2a_server.py` — `make_a2a_app(..., allowed_peer_urls=..., on_peer_reply=...)` wraps the executor in `A2AStarletteApplication`.
- `src/a2a_client.py` — `send_a2a(peer_url, data)` one-shot sender, plus `is_allowed_peer(url, allowed)` for origin-based peer URL validation.
- `src/messages.py` — `AskMessage` / `ReplyMessage` Pydantic models with a `kind` discriminator.

**Cards & state**
- `src/cards.py` — `ask_card(sender, question, qid)` (submit carries only qid), `reply_card(...)`.
- `src/state.py` — `BotState` (operator conversation, outbound asks awaiting a reply, inbound asks awaiting an operator).

## Operator model

Each bot remembers the last **1:1** Teams conversation that messaged it (`state.operator_conv_id`). Incoming asks are pushed into that conversation.

## Peer authorization

The `reply_url` check in `is_allowed_peer` is a **demo-only** stand-in for authorization: a peer is trusted because its URL matches a configured origin. Production A2A should verify the caller's identity with a bearer token signed by an IdP or mTLS, not a self-declared URL.

## Configuration

Create `.env` in `examples/a2a-test/`:

```dotenv
# Shared — your Microsoft tenant
TENANT_ID=<your-tenant-id>

# Azure OpenAI — used by both bots' LLM
AZURE_OPENAI_API_KEY=<key>
AZURE_OPENAI_ENDPOINT=<endpoint>
AZURE_OPENAI_MODEL=<deployment-name>

# Bot A (Alice) — Teams app registration
BOT_A_CLIENT_ID=<alice-client-id>
BOT_A_CLIENT_SECRET=<alice-client-secret>

# Bot B (Bob) — Teams app registration
BOT_B_CLIENT_ID=<bob-client-id>
BOT_B_CLIENT_SECRET=<bob-client-secret>

# Optional — ports and A2A peer URLs (defaults shown)
# BOT_A_HOST=localhost
# BOT_A_PORT=3978
# BOB_A2A_URL=http://localhost:3979/a2a/
# BOT_B_HOST=localhost
# BOT_B_PORT=3979
# ALICE_A2A_URL=http://localhost:3978/a2a/
```

Each bot needs its **own** Teams app registration so DMs route to the right bot.

## Run

Two terminals from `examples/a2a-test/`:

```bash
uv run python src/bot_a.py # Alice — Teams + A2A on 3978
uv run python src/bot_b.py # Bob — Teams + A2A on 3979
```

> ⚠ **DM each bot once before relaying.** The operator's conversation id is captured from the first Teams message the bot receives. If a peer ask arrives before its target has been DM'd, the target will log `no operator conversation; ask not pushed` and the card won't appear anywhere.

### Try it

With both bots DM'd at least once, try this transcript against Alice:

```
C: What's the weather like?
S: Could you please specify the location for which you'd like to know the weather?
C: London
S: The weather in London is sunny
C: What's the weather like in Tokyo?
S: The weather in Tokyo is sunny
```
You → Alice: how do I scale my postgres database?
Alice → You: Asked Bob, will let you know…
(Bob's operator gets an ask card, types "use read replicas + pgbouncer", submits)
Alice → You: [reply card] Bob says: use read replicas + pgbouncer
...... conversation ...
You → Alice: how do I scale my postgres database again, i forgot ?
Alice → You: ... Bob’s short recommendation earlier was: read replicas + PgBouncer. ...
```

The bots are symmetric — DM Bob with a UX question(eg. What's the best way to design a website?) and the same flow runs the other way (Bob's LLM forwards to Alice).
17 changes: 9 additions & 8 deletions examples/a2a-test/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
[project]
name = "a2a"
name = "a2a-test"
version = "0.1.0"
description = "sample showcasing a2a server and client"
description = "Two Teams bots talking to each other via the official a2a-sdk, exchanging Adaptive Cards"
readme = "README.md"
requires-python = ">=3.12,<3.15"
dependencies = [
"dotenv>=0.9.9",
"microsoft-teams-apps",
"microsoft-teams-a2a",
"microsoft-teams-ai",
"microsoft-teams-openai",
"microsoft-teams-common",
"microsoft-teams-cards",
"a2a-sdk[core,http-server]>=0.3.7",
"agent-framework-core",
"agent-framework-openai",
"uvicorn>=0.30",
"httpx>=0.27",
]

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

import uuid
from typing import Any
from urllib.parse import urlsplit

import httpx
from a2a.client import A2ACardResolver, ClientConfig, ClientFactory
from a2a.types import DataPart, Message, Part, Role

# Outbound A2A helpers + peer URL allowlist check.

_DEFAULT_PORTS = {"http": 80, "https": 443}


def _origin(url: str) -> tuple[str, str, int] | None:
try:
parts = urlsplit(url)
except ValueError:
return None
scheme = parts.scheme.lower()
host = (parts.hostname or "").lower()
if not scheme or not host:
return None
port = parts.port if parts.port is not None else _DEFAULT_PORTS.get(scheme, 0)
return (scheme, host, port)


def is_allowed_peer(url: str, allowed: list[str]) -> bool:
# Demo-only stand-in for authorization: we trust a peer because its
# reply_url matches a configured origin. Production A2A should verify the
# caller's identity (e.g. bearer token signed by an IdP, or mTLS) rather
# than trusting a self-declared URL. Match by scheme/host/port so a
# trailing slash or default port doesn't flip a valid peer to invalid.
target = _origin(url)
if target is None:
return False
for candidate in allowed:
candidate_origin = _origin(candidate)
if candidate_origin is not None and candidate_origin == target:
return True
return False


async def send_a2a(peer_url: str, data: dict[str, Any]) -> None:
# Resolve the peer's agent card, build an a2a-sdk client, and fire a
# single DataPart-carrying message. We drain the response stream
# without reading it — the peer only sends an `ack`; any "real"
# answer comes later as a separate inbound A2A call back to us.
async with httpx.AsyncClient(timeout=60.0) as http_client:
peer_card = await A2ACardResolver(httpx_client=http_client, base_url=peer_url).get_agent_card()
factory = ClientFactory(ClientConfig(httpx_client=http_client, streaming=True))
client = factory.create(peer_card)

request = Message(
message_id=str(uuid.uuid4()),
role=Role.user,
parts=[Part(root=DataPart(data=data))],
)
async for _ in client.send_message(request):
pass
Loading
Loading