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
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

from typing import Any
from typing import Any, Optional

from pydantic import ConfigDict
from .agents_model import AgentsModel
Expand All @@ -27,11 +27,11 @@ class ChannelAccount(AgentsModel):

id: NonEmptyString = None
name: str = None
aad_object_id: NonEmptyString = None
role: NonEmptyString = None
agentic_user_id: NonEmptyString = None
agentic_app_id: NonEmptyString = None
tenant_id: NonEmptyString = None
aad_object_id: Optional[NonEmptyString] = None
role: Optional[NonEmptyString] = None
agentic_user_id: Optional[NonEmptyString] = None
agentic_app_id: Optional[NonEmptyString] = None
tenant_id: Optional[NonEmptyString] = None

@property
def properties(self) -> dict[str, Any]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@ class ConversationAccount(AgentsModel):
:type properties: object
"""

is_group: bool = None
is_group: Optional[bool] = None
conversation_type: NonEmptyString = None
id: NonEmptyString
name: NonEmptyString = None
aad_object_id: NonEmptyString = None
role: NonEmptyString = None
name: Optional[NonEmptyString] = None
aad_object_id: Optional[NonEmptyString] = None
role: Optional[NonEmptyString] = None
tenant_id: Optional[NonEmptyString] = None
properties: object = None
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,8 @@ def get_token_audience(self) -> str:

:return: The token audience.
"""
return f"app://{self.get_outgoing_app_id()}"
return (
f"app://{self.get_outgoing_app_id()}"
if self.is_agent_claim()
else AuthenticationConstants.AGENTS_SDK_SCOPE
)
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,10 @@ async def continue_conversation_with_claims(
:type audience: Optional[str]
"""
return await self.process_proactive(
claims_identity, continuation_activity, audience, callback
claims_identity,
continuation_activity,
audience or claims_identity.get_token_audience(),
callback,
)

async def create_conversation( # pylint: disable=arguments-differ
Expand Down
44 changes: 44 additions & 0 deletions test_samples/app_style/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# App-style samples

This folder contains end-to-end samples that resemble production “app-style” experiences built on the Microsoft 365 Agents Python SDK. The new proactive messaging sample shows how to start a Microsoft Teams conversation or send a proactive message to an existing one.

## Proactive messaging sample

`proactive_messaging_agent.py` hosts two HTTP endpoints:

- `POST /api/createconversation` – creates a new 1:1 Teams conversation with a user and optionally sends an initial message.
- `POST /api/sendmessage` – sends another proactive message to an existing conversation id.

### Prerequisites

1. Python 3.10 or later.
2. Install the Agents Python SDK packages (for example by running `pip install -e libraries/microsoft-agents-*`).
3. A published Copilot Studio agent configured for Teams with application (client) ID, client secret, and tenant ID.

### Configure environment variables

1. Copy `env.TEMPLATE` to `.env` if you have not already.
2. Populate the connection settings used to acquire tokens:
- `CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID`
- `CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET`
- `CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID`
3. Add the proactive messaging settings from the template (bot id, agent id, tenant id, service URL, etc.). Optionally set `PROACTIVEMESSAGING__USERAADOBJECTID` to provide a default recipient.
4. Leave `TOKENVALIDATION__ENABLED=false` for local testing. Set it to `true` and supply a valid bearer token when calling the APIs if you need auth checks.

### Run the sample

```pwsh
python proactive_messaging_agent.py
```

The server listens on `http://localhost:5199` by default. Use the following helper commands to exercise the endpoints (replace the sample values with your own IDs):

```pwsh
# Create a new conversation (returns conversationId)
Invoke-RestMethod -Method POST -Uri "http://localhost:5199/api/createconversation" -ContentType "application/json" -Body (@{ Message = "Hello from proactive sample"; UserAadObjectId = "00000000-0000-0000-0000-000000000123" } | ConvertTo-Json)

# Send another proactive message
Invoke-RestMethod -Method POST -Uri "http://localhost:5199/api/sendmessage" -ContentType "application/json" -Body (@{ ConversationId = "<conversation-id>"; Message = "Second proactive ping" } | ConvertTo-Json)
```

When `TOKENVALIDATION__ENABLED` is `true`, add an `Authorization: Bearer <token>` header to each call. The proactive endpoints will respond with JSON payloads describing success or validation errors.
285 changes: 285 additions & 0 deletions test_samples/app_style/echo_proactive_agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
"""Echo skill sample that mirrors the Copilot Studio EchoSkill agent."""

from __future__ import annotations

import json
import logging
from dataclasses import dataclass
from os import environ, path
from typing import Any, Dict, Optional

from aiohttp import web
from dotenv import load_dotenv

from microsoft_agents.activity import (
load_configuration_from_env,
Activity,
ConversationReference,
EndOfConversationCodes,
)
from microsoft_agents.authentication.msal import MsalConnectionManager
from microsoft_agents.hosting.aiohttp import CloudAdapter, start_agent_process
from microsoft_agents.hosting.core import (
AgentApplication,
Authorization,
MemoryStorage,
MessageFactory,
TurnContext,
TurnState,
)
from microsoft_agents.hosting.core.authorization import ClaimsIdentity
from microsoft_agents.hosting.core.storage import StoreItem


@dataclass
class SendActivityRequest:
"""Request payload used to resume conversations proactively."""

conversation_id: str
message: str

@classmethod
def from_dict(cls, payload: Dict[str, Any]) -> "SendActivityRequest":
conversation_id = payload.get("conversationId") or payload.get(
"conversation_id"
)
if not conversation_id:
raise ValueError("conversationId is required.")

message = payload.get("message")
if not message:
raise ValueError("message is required.")

return cls(conversation_id=conversation_id, message=message)


@dataclass
class ConversationReferenceRecord(StoreItem):
"""Persistent envelope for a conversation reference and associated identity."""

claims: dict[str, str]
is_authenticated: bool
authentication_type: Optional[str]
reference: ConversationReference

@staticmethod
def get_key(conversation_id: str) -> str:
return f"conversationreferences/{conversation_id}"

@property
def key(self) -> str:
return self.get_key(self.reference.conversation.id)

@classmethod
def from_context(cls, context: TurnContext) -> "ConversationReferenceRecord":
identity = context.identity or ClaimsIdentity({}, False)
reference = context.activity.get_conversation_reference()
return cls(
claims=dict(identity.claims),
is_authenticated=identity.is_authenticated,
authentication_type=identity.authentication_type,
reference=reference,
)

def to_identity(self) -> ClaimsIdentity:
return ClaimsIdentity(
claims=dict(self.claims),
is_authenticated=self.is_authenticated,
authentication_type=self.authentication_type,
)

def store_item_to_json(self) -> Dict[str, Any]:
return {
"claims": dict(self.claims),
"is_authenticated": self.is_authenticated,
"authentication_type": self.authentication_type,
"reference": self.reference.model_dump(mode="json"),
}

@staticmethod
def from_json_to_store_item(
json_data: Dict[str, Any],
) -> "ConversationReferenceRecord":
reference_payload = json_data.get("reference")
if not reference_payload:
raise ValueError("Conversation reference payload is missing.")

reference = ConversationReference.model_validate(
reference_payload, strict=False
)
return ConversationReferenceRecord(
claims=json_data.get("claims", {}),
is_authenticated=json_data.get("is_authenticated", False),
authentication_type=json_data.get("authentication_type"),
reference=reference,
)


load_dotenv(path.join(path.dirname(__file__), ".env"))
agents_sdk_config = load_configuration_from_env(environ)

storage = MemoryStorage()
connection_manager = MsalConnectionManager(**agents_sdk_config)
adapter = CloudAdapter(connection_manager=connection_manager)
authorization = Authorization(storage, connection_manager, **agents_sdk_config)
AGENT_APP = AgentApplication[TurnState](
storage=storage,
adapter=adapter,
authorization=authorization,
**agents_sdk_config.get("AGENTAPPLICATION", {}),
)


@AGENT_APP.activity("message")
async def on_message(context: TurnContext, state: TurnState) -> None:
text = context.activity.text or ""
if "end" == text:
await context.send_activity("(EchoSkill) Ending conversation...")
end_activity = Activity.create_end_of_conversation_activity()
end_activity.code = EndOfConversationCodes.completed_successfully
await context.send_activity(end_activity)
await state.conversation.delete(context)
conversation = context.activity.conversation
if conversation and conversation.id:
await state.conversation._storage.delete(
[ConversationReferenceRecord.get_key(conversation.id)]
)
return

logging.info(
f"(EchoSkill) ConversationReference to save: {context.activity.get_conversation_reference().model_dump(mode='json', exclude_unset=True, by_alias=True)} with message: {text}"
)
record = ConversationReferenceRecord.from_context(context)
await state.conversation._storage.write({record.key: record})

await context.send_activity(MessageFactory.text(f"(EchoSkill): {text}"))


class EchoSkillService:
def __init__(
self,
storage: MemoryStorage,
adapter: CloudAdapter,
) -> None:
self._storage = storage
self._adapter = adapter

async def send_activity_to_conversation(
self, conversation_id: str, message: str
) -> bool:
if not conversation_id:
return False

key = ConversationReferenceRecord.get_key(conversation_id)
items: Dict[str, ConversationReferenceRecord] = await self._storage.read(
[key], target_cls=ConversationReferenceRecord
)
record = items.get(key)
if not record:
return False

continuation_activity = record.reference.get_continuation_activity()

async def _callback(turn_context: TurnContext) -> None:
await turn_context.send_activity(message)

await self._adapter.continue_conversation_with_claims(
record.to_identity(), continuation_activity, _callback
)
return True


async def _read_optional_json(request: web.Request) -> Dict[str, Any]:
if request.content_length in (0, None):
return {}
try:
return await request.json()
except json.JSONDecodeError:
return {}


def create_app() -> web.Application:
"""Create and configure the aiohttp application hosting the sample."""

load_dotenv(path.join(path.dirname(__file__), ".env"))

echo_service = EchoSkillService(storage, adapter)
global SERVICE_INSTANCE
SERVICE_INSTANCE = echo_service

app = web.Application()
app["adapter"] = adapter
app["agent_app"] = AGENT_APP
app["echo_service"] = echo_service
agent_config = connection_manager.get_default_connection_configuration()
if not agent_config:
raise ValueError("SERVICE_CONNECTION settings are missing.")
app["agent_configuration"] = agent_config

app.router.add_get("/", _handle_root)
app.router.add_post("/api/messages", _agent_entry_point)
app.router.add_post("/api/sendactivity", _handle_send_activity)

return app


async def _handle_root(request: web.Request) -> web.Response:
return web.json_response({"status": "ready", "sample": "echo-skill"})


async def _agent_entry_point(request: web.Request) -> web.Response:
agent_app: AgentApplication = request.app["agent_app"]
adapter: CloudAdapter = request.app["adapter"]
response = await start_agent_process(request, agent_app, adapter)
return response or web.Response(status=202)


async def _handle_send_activity(request: web.Request) -> web.Response:
service: EchoSkillService = request.app["echo_service"]
payload = await _read_optional_json(request)

try:
send_request = SendActivityRequest.from_dict(payload)
except ValueError as exc:
return web.json_response(
{
"status": "Error",
"error": {"code": "Validation", "message": str(exc)},
},
status=400,
)

success = await service.send_activity_to_conversation(
send_request.conversation_id, send_request.message
)

if not success:
return web.json_response(
{
"status": "Error",
"error": {
"code": "NotFound",
"message": "Conversation reference not found.",
},
},
status=404,
)

return web.json_response(
{"status": "Delivered", "conversationId": send_request.conversation_id},
status=202,
)


def main() -> None:
logging.basicConfig(level=logging.INFO)
app = create_app()

host = environ.get("HOST", "localhost")
port = int(environ.get("PORT", "3978"))

web.run_app(app, host=host, port=port)


if __name__ == "__main__":
main()
Loading