microsoft-agents-hosting-aiohttp 0.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. microsoft_agents_hosting_aiohttp-0.0.0/PKG-INFO +13 -0
  2. microsoft_agents_hosting_aiohttp-0.0.0/microsoft/agents/hosting/aiohttp/__init__.py +25 -0
  3. microsoft_agents_hosting_aiohttp-0.0.0/microsoft/agents/hosting/aiohttp/_start_agent_process.py +26 -0
  4. microsoft_agents_hosting_aiohttp-0.0.0/microsoft/agents/hosting/aiohttp/agent_http_adapter.py +18 -0
  5. microsoft_agents_hosting_aiohttp-0.0.0/microsoft/agents/hosting/aiohttp/app/__init__.py +14 -0
  6. microsoft_agents_hosting_aiohttp-0.0.0/microsoft/agents/hosting/aiohttp/app/streaming/__init__.py +12 -0
  7. microsoft_agents_hosting_aiohttp-0.0.0/microsoft/agents/hosting/aiohttp/app/streaming/citation.py +22 -0
  8. microsoft_agents_hosting_aiohttp-0.0.0/microsoft/agents/hosting/aiohttp/app/streaming/citation_util.py +85 -0
  9. microsoft_agents_hosting_aiohttp-0.0.0/microsoft/agents/hosting/aiohttp/app/streaming/streaming_response.py +412 -0
  10. microsoft_agents_hosting_aiohttp-0.0.0/microsoft/agents/hosting/aiohttp/channel_service_route_table.py +194 -0
  11. microsoft_agents_hosting_aiohttp-0.0.0/microsoft/agents/hosting/aiohttp/cloud_adapter.py +114 -0
  12. microsoft_agents_hosting_aiohttp-0.0.0/microsoft/agents/hosting/aiohttp/jwt_authorization_middleware.py +62 -0
  13. microsoft_agents_hosting_aiohttp-0.0.0/microsoft_agents_hosting_aiohttp.egg-info/PKG-INFO +13 -0
  14. microsoft_agents_hosting_aiohttp-0.0.0/microsoft_agents_hosting_aiohttp.egg-info/SOURCES.txt +18 -0
  15. microsoft_agents_hosting_aiohttp-0.0.0/microsoft_agents_hosting_aiohttp.egg-info/dependency_links.txt +1 -0
  16. microsoft_agents_hosting_aiohttp-0.0.0/microsoft_agents_hosting_aiohttp.egg-info/requires.txt +2 -0
  17. microsoft_agents_hosting_aiohttp-0.0.0/microsoft_agents_hosting_aiohttp.egg-info/top_level.txt +1 -0
  18. microsoft_agents_hosting_aiohttp-0.0.0/pyproject.toml +18 -0
  19. microsoft_agents_hosting_aiohttp-0.0.0/setup.cfg +4 -0
  20. microsoft_agents_hosting_aiohttp-0.0.0/setup.py +12 -0
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: microsoft-agents-hosting-aiohttp
3
+ Version: 0.0.0
4
+ Summary: Integration library for Microsoft Agents with aiohttp
5
+ Author: Microsoft Corporation
6
+ Project-URL: Homepage, https://github.com/microsoft/Agents
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.9
11
+ Requires-Dist: microsoft-agents-hosting-core==0.0.0
12
+ Requires-Dist: aiohttp>=3.11.11
13
+ Dynamic: requires-dist
@@ -0,0 +1,25 @@
1
+ from ._start_agent_process import start_agent_process
2
+ from .agent_http_adapter import AgentHttpAdapter
3
+ from .channel_service_route_table import channel_service_route_table
4
+ from .cloud_adapter import CloudAdapter
5
+ from .jwt_authorization_middleware import (
6
+ jwt_authorization_middleware,
7
+ jwt_authorization_decorator,
8
+ )
9
+ from .app.streaming import (
10
+ Citation,
11
+ CitationUtil,
12
+ StreamingResponse,
13
+ )
14
+
15
+ __all__ = [
16
+ "start_agent_process",
17
+ "AgentHttpAdapter",
18
+ "CloudAdapter",
19
+ "jwt_authorization_middleware",
20
+ "jwt_authorization_decorator",
21
+ "channel_service_route_table",
22
+ "Citation",
23
+ "CitationUtil",
24
+ "StreamingResponse",
25
+ ]
@@ -0,0 +1,26 @@
1
+ from typing import Optional
2
+ from aiohttp.web import Request, Response
3
+ from microsoft.agents.hosting.core.app import AgentApplication
4
+ from .cloud_adapter import CloudAdapter
5
+
6
+
7
+ async def start_agent_process(
8
+ request: Request,
9
+ agent_application: AgentApplication,
10
+ adapter: CloudAdapter,
11
+ ) -> Optional[Response]:
12
+ """Starts the agent host with the provided adapter and agent application.
13
+ Args:
14
+ adapter (CloudAdapter): The adapter to use for the agent host.
15
+ agent_application (AgentApplication): The agent application to run.
16
+ """
17
+ if not adapter:
18
+ raise TypeError("start_agent_process: adapter can't be None")
19
+ if not agent_application:
20
+ raise TypeError("start_agent_process: agent_application can't be None")
21
+
22
+ # Start the agent application with the provided adapter
23
+ return await adapter.process(
24
+ request,
25
+ agent_application,
26
+ )
@@ -0,0 +1,18 @@
1
+ # Copyright (c) Microsoft Corporation. All rights reserved.
2
+ # Licensed under the MIT License.
3
+
4
+ from abc import abstractmethod
5
+ from typing import Optional, Protocol
6
+
7
+ from aiohttp.web import (
8
+ Request,
9
+ Response,
10
+ )
11
+
12
+ from microsoft.agents.hosting.core import Agent
13
+
14
+
15
+ class AgentHttpAdapter(Protocol):
16
+ @abstractmethod
17
+ async def process(self, request: Request, agent: Agent) -> Optional[Response]:
18
+ raise NotImplementedError()
@@ -0,0 +1,14 @@
1
+ # Copyright (c) Microsoft Corporation. All rights reserved.
2
+ # Licensed under the MIT License.
3
+
4
+ from .streaming import (
5
+ Citation,
6
+ CitationUtil,
7
+ StreamingResponse,
8
+ )
9
+
10
+ __all__ = [
11
+ "Citation",
12
+ "CitationUtil",
13
+ "StreamingResponse",
14
+ ]
@@ -0,0 +1,12 @@
1
+ # Copyright (c) Microsoft Corporation. All rights reserved.
2
+ # Licensed under the MIT License.
3
+
4
+ from .citation import Citation
5
+ from .citation_util import CitationUtil
6
+ from .streaming_response import StreamingResponse
7
+
8
+ __all__ = [
9
+ "Citation",
10
+ "CitationUtil",
11
+ "StreamingResponse",
12
+ ]
@@ -0,0 +1,22 @@
1
+ # Copyright (c) Microsoft Corporation. All rights reserved.
2
+ # Licensed under the MIT License.
3
+
4
+ from typing import Optional
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass
9
+ class Citation:
10
+ """Citations returned by the model."""
11
+
12
+ content: str
13
+ """The content of the citation."""
14
+
15
+ title: Optional[str] = None
16
+ """The title of the citation."""
17
+
18
+ url: Optional[str] = None
19
+ """The URL of the citation."""
20
+
21
+ filepath: Optional[str] = None
22
+ """The filepath of the document."""
@@ -0,0 +1,85 @@
1
+ # Copyright (c) Microsoft Corporation. All rights reserved.
2
+ # Licensed under the MIT License.
3
+
4
+ import re
5
+ from typing import List, Optional
6
+
7
+ from microsoft.agents.activity import ClientCitation
8
+
9
+
10
+ class CitationUtil:
11
+ """Utility functions for manipulating text and citations."""
12
+
13
+ @staticmethod
14
+ def snippet(text: str, max_length: int) -> str:
15
+ """
16
+ Clips the text to a maximum length in case it exceeds the limit.
17
+
18
+ Args:
19
+ text: The text to clip.
20
+ max_length: The maximum length of the text to return, cutting off the last whole word.
21
+
22
+ Returns:
23
+ The modified text
24
+ """
25
+ if len(text) <= max_length:
26
+ return text
27
+
28
+ snippet = text[:max_length]
29
+ snippet = snippet[: min(len(snippet), snippet.rfind(" "))]
30
+ snippet += "..."
31
+ return snippet
32
+
33
+ @staticmethod
34
+ def format_citations_response(text: str) -> str:
35
+ """
36
+ Convert citation tags `[doc(s)n]` to `[n]` where n is a number.
37
+
38
+ Args:
39
+ text: The text to format.
40
+
41
+ Returns:
42
+ The formatted text.
43
+ """
44
+ return re.sub(r"\[docs?(\d+)\]", r"[\1]", text, flags=re.IGNORECASE)
45
+
46
+ @staticmethod
47
+ def get_used_citations(
48
+ text: str, citations: List[ClientCitation]
49
+ ) -> Optional[List[ClientCitation]]:
50
+ """
51
+ Get the citations used in the text. This will remove any citations that are
52
+ included in the citations array from the response but not referenced in the text.
53
+
54
+ Args:
55
+ text: The text to search for citation references, i.e. [1], [2], etc.
56
+ citations: The list of citations to search for.
57
+
58
+ Returns:
59
+ The list of citations used in the text.
60
+ """
61
+ regex = re.compile(r"\[(\d+)\]", re.IGNORECASE)
62
+ matches = regex.findall(text)
63
+
64
+ if not matches:
65
+ return None
66
+
67
+ # Remove duplicates
68
+ filtered_matches = set(matches)
69
+
70
+ # Add citations
71
+ used_citations = []
72
+ for match in filtered_matches:
73
+ citation_ref = f"[{match}]"
74
+ found = next(
75
+ (
76
+ citation
77
+ for citation in citations
78
+ if f"[{citation.position}]" == citation_ref
79
+ ),
80
+ None,
81
+ )
82
+ if found:
83
+ used_citations.append(found)
84
+
85
+ return used_citations if used_citations else None
@@ -0,0 +1,412 @@
1
+ # Copyright (c) Microsoft Corporation. All rights reserved.
2
+ # Licensed under the MIT License.
3
+
4
+ import asyncio
5
+ import logging
6
+ from typing import List, Optional, Callable, Literal, TYPE_CHECKING
7
+ from dataclasses import dataclass
8
+
9
+ from microsoft.agents.activity import (
10
+ Activity,
11
+ Entity,
12
+ Attachment,
13
+ Channels,
14
+ ClientCitation,
15
+ DeliveryModes,
16
+ SensitivityUsageInfo,
17
+ add_ai_to_activity,
18
+ )
19
+
20
+ if TYPE_CHECKING:
21
+ from microsoft.agents.hosting.core.turn_context import TurnContext
22
+
23
+ from .citation import Citation
24
+ from .citation_util import CitationUtil
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class StreamingResponse:
30
+ """
31
+ A helper class for streaming responses to the client.
32
+
33
+ This class is used to send a series of updates to the client in a single response.
34
+ The expected sequence of calls is:
35
+
36
+ `queue_informative_update()`, `queue_text_chunk()`, `queue_text_chunk()`, ..., `end_stream()`.
37
+
38
+ Once `end_stream()` is called, the stream is considered ended and no further updates can be sent.
39
+ """
40
+
41
+ def __init__(self, context: "TurnContext"):
42
+ """
43
+ Creates a new StreamingResponse instance.
44
+
45
+ Args:
46
+ context: Context for the current turn of conversation with the user.
47
+ """
48
+ self._context = context
49
+ self._sequence_number = 1
50
+ self._stream_id: Optional[str] = None
51
+ self._message = ""
52
+ self._attachments: Optional[List[Attachment]] = None
53
+ self._ended = False
54
+ self._cancelled = False
55
+
56
+ # Queue for outgoing activities
57
+ self._queue: List[Callable[[], Activity]] = []
58
+ self._queue_sync: Optional[asyncio.Task] = None
59
+ self._chunk_queued = False
60
+
61
+ # Powered by AI feature flags
62
+ self._enable_feedback_loop = False
63
+ self._feedback_loop_type: Optional[Literal["default", "custom"]] = None
64
+ self._enable_generated_by_ai_label = False
65
+ self._citations: Optional[List[ClientCitation]] = []
66
+ self._sensitivity_label: Optional[SensitivityUsageInfo] = None
67
+
68
+ # Channel information
69
+ self._is_streaming_channel: bool = False
70
+ self._channel_id: Channels = None
71
+ self._interval: float = 0.1 # Default interval for sending updates
72
+ self._set_defaults(context)
73
+
74
+ @property
75
+ def stream_id(self) -> Optional[str]:
76
+ """
77
+ Gets the stream ID of the current response.
78
+ Assigned after the initial update is sent.
79
+ """
80
+ return self._stream_id
81
+
82
+ @property
83
+ def citations(self) -> Optional[List[ClientCitation]]:
84
+ """Gets the citations of the current response."""
85
+ return self._citations
86
+
87
+ @property
88
+ def updates_sent(self) -> int:
89
+ """Gets the number of updates sent for the stream."""
90
+ return self._sequence_number - 1
91
+
92
+ def queue_informative_update(self, text: str) -> None:
93
+ """
94
+ Queues an informative update to be sent to the client.
95
+
96
+ Args:
97
+ text: Text of the update to send.
98
+ """
99
+ if not self._is_streaming_channel:
100
+ return
101
+
102
+ if self._ended:
103
+ raise RuntimeError("The stream has already ended.")
104
+
105
+ # Queue a typing activity
106
+ def create_activity():
107
+ activity = Activity(
108
+ type="typing",
109
+ text=text,
110
+ entities=[
111
+ Entity(
112
+ type="streaminfo",
113
+ stream_type="informative",
114
+ stream_sequence=self._sequence_number,
115
+ )
116
+ ],
117
+ )
118
+ self._sequence_number += 1
119
+ return activity
120
+
121
+ self._queue_activity(create_activity)
122
+
123
+ def queue_text_chunk(
124
+ self, text: str, citations: Optional[List[Citation]] = None
125
+ ) -> None:
126
+ """
127
+ Queues a chunk of partial message text to be sent to the client.
128
+
129
+ The text will be sent as quickly as possible to the client.
130
+ Chunks may be combined before delivery to the client.
131
+
132
+ Args:
133
+ text: Partial text of the message to send.
134
+ citations: Citations to be included in the message.
135
+ """
136
+ if self._cancelled:
137
+ return
138
+ if self._ended:
139
+ raise RuntimeError("The stream has already ended.")
140
+
141
+ # Update full message text
142
+ self._message += text
143
+
144
+ # If there are citations, modify the content so that the sources are numbers instead of [doc1], [doc2], etc.
145
+ self._message = CitationUtil.format_citations_response(self._message)
146
+
147
+ # Queue the next chunk
148
+ self._queue_next_chunk()
149
+
150
+ async def end_stream(self) -> None:
151
+ """
152
+ Ends the stream by sending the final message to the client.
153
+ """
154
+ if self._ended:
155
+ raise RuntimeError("The stream has already ended.")
156
+
157
+ # Queue final message
158
+ self._ended = True
159
+ self._queue_next_chunk()
160
+
161
+ # Wait for the queue to drain
162
+ await self.wait_for_queue()
163
+
164
+ def set_attachments(self, attachments: List[Attachment]) -> None:
165
+ """
166
+ Sets the attachments to attach to the final chunk.
167
+
168
+ Args:
169
+ attachments: List of attachments.
170
+ """
171
+ self._attachments = attachments
172
+
173
+ def set_sensitivity_label(self, sensitivity_label: SensitivityUsageInfo) -> None:
174
+ """
175
+ Sets the sensitivity label to attach to the final chunk.
176
+
177
+ Args:
178
+ sensitivity_label: The sensitivity label.
179
+ """
180
+ self._sensitivity_label = sensitivity_label
181
+
182
+ def set_citations(self, citations: List[Citation]) -> None:
183
+ """
184
+ Sets the citations for the full message.
185
+
186
+ Args:
187
+ citations: Citations to be included in the message.
188
+ """
189
+ if citations:
190
+ if not self._citations:
191
+ self._citations = []
192
+
193
+ curr_pos = len(self._citations)
194
+
195
+ for citation in citations:
196
+ client_citation = ClientCitation(
197
+ type="Claim",
198
+ position=curr_pos + 1,
199
+ appearance={
200
+ "type": "DigitalDocument",
201
+ "name": citation.title or f"Document #{curr_pos + 1}",
202
+ "abstract": CitationUtil.snippet(citation.content, 477),
203
+ },
204
+ )
205
+ curr_pos += 1
206
+ self._citations.append(client_citation)
207
+
208
+ def set_feedback_loop(self, enable_feedback_loop: bool) -> None:
209
+ """
210
+ Sets the Feedback Loop in Teams that allows a user to
211
+ give thumbs up or down to a response.
212
+ Default is False.
213
+
214
+ Args:
215
+ enable_feedback_loop: If true, the feedback loop is enabled.
216
+ """
217
+ self._enable_feedback_loop = enable_feedback_loop
218
+
219
+ def set_feedback_loop_type(
220
+ self, feedback_loop_type: Literal["default", "custom"]
221
+ ) -> None:
222
+ """
223
+ Sets the type of UI to use for the feedback loop.
224
+
225
+ Args:
226
+ feedback_loop_type: The type of the feedback loop.
227
+ """
228
+ self._feedback_loop_type = feedback_loop_type
229
+
230
+ def set_generated_by_ai_label(self, enable_generated_by_ai_label: bool) -> None:
231
+ """
232
+ Sets the Generated by AI label in Teams.
233
+ Default is False.
234
+
235
+ Args:
236
+ enable_generated_by_ai_label: If true, the label is added.
237
+ """
238
+ self._enable_generated_by_ai_label = enable_generated_by_ai_label
239
+
240
+ def get_message(self) -> str:
241
+ """
242
+ Returns the most recently streamed message.
243
+ """
244
+ return self._message
245
+
246
+ async def wait_for_queue(self) -> None:
247
+ """
248
+ Waits for the outgoing activity queue to be empty.
249
+ """
250
+ if self._queue_sync:
251
+ await self._queue_sync
252
+
253
+ def _set_defaults(self, context: "TurnContext"):
254
+ if context.activity.channel_id == Channels.ms_teams:
255
+ self._is_streaming_channel = True
256
+ self._interval = 1.0
257
+ elif context.activity.channel_id == Channels.direct_line:
258
+ self._is_streaming_channel = True
259
+ self._interval = 0.5
260
+ elif context.activity.delivery_mode == DeliveryModes.stream:
261
+ self._is_streaming_channel = True
262
+ self._interval = 0.1
263
+
264
+ self._channel_id = context.activity.channel_id
265
+
266
+ def _queue_next_chunk(self) -> None:
267
+ """
268
+ Queues the next chunk of text to be sent to the client.
269
+ """
270
+ # Are we already waiting to send a chunk?
271
+ if self._chunk_queued:
272
+ return
273
+
274
+ # Queue a chunk of text to be sent
275
+ self._chunk_queued = True
276
+
277
+ def create_activity():
278
+ self._chunk_queued = False
279
+ if self._ended:
280
+ # Send final message
281
+ activity = Activity(
282
+ type="message",
283
+ text=self._message or "end stream response",
284
+ attachments=self._attachments or [],
285
+ entities=[
286
+ Entity(
287
+ type="streaminfo",
288
+ stream_type="final",
289
+ stream_sequence=self._sequence_number,
290
+ )
291
+ ],
292
+ )
293
+ elif self._is_streaming_channel:
294
+ # Send typing activity
295
+ activity = Activity(
296
+ type="typing",
297
+ text=self._message,
298
+ entities=[
299
+ Entity(
300
+ type="streaminfo",
301
+ stream_type="streaming",
302
+ stream_sequence=self._sequence_number,
303
+ )
304
+ ],
305
+ )
306
+ else:
307
+ return
308
+ self._sequence_number += 1
309
+ return activity
310
+
311
+ self._queue_activity(create_activity)
312
+
313
+ def _queue_activity(self, factory: Callable[[], Activity]) -> None:
314
+ """
315
+ Queues an activity to be sent to the client.
316
+ """
317
+ self._queue.append(factory)
318
+
319
+ # If there's no sync in progress, start one
320
+ if not self._queue_sync:
321
+ self._queue_sync = asyncio.create_task(self._drain_queue())
322
+
323
+ async def _drain_queue(self) -> None:
324
+ """
325
+ Sends any queued activities to the client until the queue is empty.
326
+ """
327
+ try:
328
+ logger.debug(f"Draining queue with {len(self._queue)} activities.")
329
+ while self._queue:
330
+ factory = self._queue.pop(0)
331
+ activity = factory()
332
+ if activity:
333
+ await self._send_activity(activity)
334
+ except Exception as err:
335
+ if (
336
+ "403" in str(err)
337
+ and self._context.activity.channel_id == Channels.ms_teams
338
+ ):
339
+ logger.warning("Teams channel stopped the stream.")
340
+ self._cancelled = True
341
+ else:
342
+ logger.error(
343
+ f"Error occurred when sending activity while streaming: {err}"
344
+ )
345
+ raise
346
+ finally:
347
+ self._queue_sync = None
348
+
349
+ async def _send_activity(self, activity: Activity) -> None:
350
+ """
351
+ Sends an activity to the client and saves the stream ID returned.
352
+
353
+ Args:
354
+ activity: The activity to send.
355
+ """
356
+
357
+ streaminfo_entity = None
358
+
359
+ if not activity.entities:
360
+ streaminfo_entity = Entity(type="streaminfo")
361
+ activity.entities = [streaminfo_entity]
362
+ else:
363
+ for entity in activity.entities:
364
+ if hasattr(entity, "type") and entity.type == "streaminfo":
365
+ streaminfo_entity = entity
366
+ break
367
+
368
+ if not streaminfo_entity:
369
+ # If no streaminfo entity exists, create one
370
+ streaminfo_entity = Entity(type="streaminfo")
371
+ activity.entities.append(streaminfo_entity)
372
+
373
+ # Set activity ID to the assigned stream ID
374
+ if self._stream_id:
375
+ activity.id = self._stream_id
376
+ streaminfo_entity.stream_id = self._stream_id
377
+
378
+ if self._citations and len(self._citations) > 0 and not self._ended:
379
+ # Filter out the citations unused in content.
380
+ curr_citations = CitationUtil.get_used_citations(
381
+ self._message, self._citations
382
+ )
383
+ if curr_citations:
384
+ activity.entities.append(
385
+ Entity(
386
+ type="https://schema.org/Message",
387
+ schema_type="Message",
388
+ context="https://schema.org",
389
+ id="",
390
+ citation=curr_citations,
391
+ )
392
+ )
393
+
394
+ # Add in Powered by AI feature flags
395
+ if self._ended:
396
+ if self._enable_feedback_loop and self._feedback_loop_type:
397
+ # Add feedback loop to streaminfo entity
398
+ streaminfo_entity.feedback_loop = {"type": self._feedback_loop_type}
399
+ else:
400
+ # Add feedback loop enabled to streaminfo entity
401
+ streaminfo_entity.feedback_loop_enabled = self._enable_feedback_loop
402
+ # Add in Generated by AI
403
+ if self._enable_generated_by_ai_label:
404
+ add_ai_to_activity(activity, self._citations, self._sensitivity_label)
405
+
406
+ # Send activity
407
+ response = await self._context.send_activity(activity)
408
+ await asyncio.sleep(self._interval)
409
+
410
+ # Save assigned stream ID
411
+ if not self._stream_id and response:
412
+ self._stream_id = response.id
@@ -0,0 +1,194 @@
1
+ # Copyright (c) Microsoft Corporation. All rights reserved.
2
+ # Licensed under the MIT License.
3
+ import json
4
+ from typing import List, Union, Type
5
+
6
+ from aiohttp.web import RouteTableDef, Request, Response
7
+
8
+ from microsoft.agents.activity import (
9
+ AgentsModel,
10
+ Activity,
11
+ AttachmentData,
12
+ ConversationParameters,
13
+ Transcript,
14
+ )
15
+ from microsoft.agents.hosting.core import ChannelApiHandlerProtocol
16
+
17
+
18
+ async def deserialize_from_body(
19
+ request: Request, target_model: Type[AgentsModel]
20
+ ) -> Activity:
21
+ if "application/json" in request.headers["Content-Type"]:
22
+ body = await request.json()
23
+ else:
24
+ return Response(status=415)
25
+
26
+ return target_model.model_validate(body)
27
+
28
+
29
+ def get_serialized_response(
30
+ model_or_list: Union[AgentsModel, List[AgentsModel]],
31
+ ) -> Response:
32
+ if isinstance(model_or_list, AgentsModel):
33
+ json_obj = model_or_list.model_dump(
34
+ mode="json", exclude_unset=True, by_alias=True
35
+ )
36
+ else:
37
+ json_obj = [
38
+ model.model_dump(mode="json", exclude_unset=True, by_alias=True)
39
+ for model in model_or_list
40
+ ]
41
+
42
+ return Response(body=json.dumps(json_obj), content_type="application/json")
43
+
44
+
45
+ def channel_service_route_table(
46
+ handler: ChannelApiHandlerProtocol, base_url: str = ""
47
+ ) -> RouteTableDef:
48
+ # pylint: disable=unused-variable
49
+ routes = RouteTableDef()
50
+
51
+ @routes.post(base_url + "/v3/conversations/{conversation_id}/activities")
52
+ async def send_to_conversation(request: Request):
53
+ activity = await deserialize_from_body(request, Activity)
54
+ result = await handler.on_send_to_conversation(
55
+ request.get("claims_identity"),
56
+ request.match_info["conversation_id"],
57
+ activity,
58
+ )
59
+
60
+ return get_serialized_response(result)
61
+
62
+ @routes.post(
63
+ base_url + "/v3/conversations/{conversation_id}/activities/{activity_id}"
64
+ )
65
+ async def reply_to_activity(request: Request):
66
+ activity = await deserialize_from_body(request, Activity)
67
+ result = await handler.on_reply_to_activity(
68
+ request.get("claims_identity"),
69
+ request.match_info["conversation_id"],
70
+ request.match_info["activity_id"],
71
+ activity,
72
+ )
73
+
74
+ return get_serialized_response(result)
75
+
76
+ @routes.put(
77
+ base_url + "/v3/conversations/{conversation_id}/activities/{activity_id}"
78
+ )
79
+ async def update_activity(request: Request):
80
+ activity = await deserialize_from_body(request, Activity)
81
+ result = await handler.on_update_activity(
82
+ request.get("claims_identity"),
83
+ request.match_info["conversation_id"],
84
+ request.match_info["activity_id"],
85
+ activity,
86
+ )
87
+
88
+ return get_serialized_response(result)
89
+
90
+ @routes.delete(
91
+ base_url + "/v3/conversations/{conversation_id}/activities/{activity_id}"
92
+ )
93
+ async def delete_activity(request: Request):
94
+ await handler.on_delete_activity(
95
+ request.get("claims_identity"),
96
+ request.match_info["conversation_id"],
97
+ request.match_info["activity_id"],
98
+ )
99
+
100
+ return Response()
101
+
102
+ @routes.get(
103
+ base_url
104
+ + "/v3/conversations/{conversation_id}/activities/{activity_id}/members"
105
+ )
106
+ async def get_activity_members(request: Request):
107
+ result = await handler.on_get_activity_members(
108
+ request.get("claims_identity"),
109
+ request.match_info["conversation_id"],
110
+ request.match_info["activity_id"],
111
+ )
112
+
113
+ return get_serialized_response(result)
114
+
115
+ @routes.post(base_url + "/")
116
+ async def create_conversation(request: Request):
117
+ conversation_parameters = deserialize_from_body(request, ConversationParameters)
118
+ result = await handler.on_create_conversation(
119
+ request.get("claims_identity"), conversation_parameters
120
+ )
121
+
122
+ return get_serialized_response(result)
123
+
124
+ @routes.get(base_url + "/")
125
+ async def get_conversation(request: Request):
126
+ # TODO: continuation token? conversation_id?
127
+ result = await handler.on_get_conversations(
128
+ request.get("claims_identity"), None
129
+ )
130
+
131
+ return get_serialized_response(result)
132
+
133
+ @routes.get(base_url + "/v3/conversations/{conversation_id}/members")
134
+ async def get_conversation_members(request: Request):
135
+ result = await handler.on_get_conversation_members(
136
+ request.get("claims_identity"),
137
+ request.match_info["conversation_id"],
138
+ )
139
+
140
+ return get_serialized_response(result)
141
+
142
+ @routes.get(base_url + "/v3/conversations/{conversation_id}/members/{member_id}")
143
+ async def get_conversation_member(request: Request):
144
+ result = await handler.on_get_conversation_member(
145
+ request.get("claims_identity"),
146
+ request.match_info["member_id"],
147
+ request.match_info["conversation_id"],
148
+ )
149
+
150
+ return get_serialized_response(result)
151
+
152
+ @routes.get(base_url + "/v3/conversations/{conversation_id}/pagedmembers")
153
+ async def get_conversation_paged_members(request: Request):
154
+ # TODO: continuation token? page size?
155
+ result = await handler.on_get_conversation_paged_members(
156
+ request.get("claims_identity"),
157
+ request.match_info["conversation_id"],
158
+ )
159
+
160
+ return get_serialized_response(result)
161
+
162
+ @routes.delete(base_url + "/v3/conversations/{conversation_id}/members/{member_id}")
163
+ async def delete_conversation_member(request: Request):
164
+ result = await handler.on_delete_conversation_member(
165
+ request.get("claims_identity"),
166
+ request.match_info["conversation_id"],
167
+ request.match_info["member_id"],
168
+ )
169
+
170
+ return get_serialized_response(result)
171
+
172
+ @routes.post(base_url + "/v3/conversations/{conversation_id}/activities/history")
173
+ async def send_conversation_history(request: Request):
174
+ transcript = deserialize_from_body(request, Transcript)
175
+ result = await handler.on_send_conversation_history(
176
+ request.get("claims_identity"),
177
+ request.match_info["conversation_id"],
178
+ transcript,
179
+ )
180
+
181
+ return get_serialized_response(result)
182
+
183
+ @routes.post(base_url + "/v3/conversations/{conversation_id}/attachments")
184
+ async def upload_attachment(request: Request):
185
+ attachment_data = deserialize_from_body(request, AttachmentData)
186
+ result = await handler.on_upload_attachment(
187
+ request.get("claims_identity"),
188
+ request.match_info["conversation_id"],
189
+ attachment_data,
190
+ )
191
+
192
+ return get_serialized_response(result)
193
+
194
+ return routes
@@ -0,0 +1,114 @@
1
+ # Copyright (c) Microsoft Corporation. All rights reserved.
2
+ # Licensed under the MIT License.
3
+ from traceback import format_exc
4
+ from typing import Optional
5
+
6
+ from aiohttp.web import (
7
+ Request,
8
+ Response,
9
+ json_response,
10
+ HTTPBadRequest,
11
+ HTTPMethodNotAllowed,
12
+ HTTPUnauthorized,
13
+ HTTPUnsupportedMediaType,
14
+ )
15
+ from microsoft.agents.hosting.core.authorization import (
16
+ ClaimsIdentity,
17
+ Connections,
18
+ )
19
+ from microsoft.agents.activity import (
20
+ Activity,
21
+ DeliveryModes,
22
+ )
23
+ from microsoft.agents.hosting.core import (
24
+ Agent,
25
+ ChannelServiceAdapter,
26
+ ChannelServiceClientFactoryBase,
27
+ MessageFactory,
28
+ RestChannelServiceClientFactory,
29
+ TurnContext,
30
+ )
31
+
32
+ from .agent_http_adapter import AgentHttpAdapter
33
+
34
+
35
+ class CloudAdapter(ChannelServiceAdapter, AgentHttpAdapter):
36
+ def __init__(
37
+ self,
38
+ *,
39
+ connection_manager: Connections,
40
+ channel_service_client_factory: ChannelServiceClientFactoryBase = None,
41
+ ):
42
+ """
43
+ Initializes a new instance of the CloudAdapter class.
44
+
45
+ :param channel_service_client_factory: The factory to use to create the channel service client.
46
+ """
47
+
48
+ async def on_turn_error(context: TurnContext, error: Exception):
49
+ error_message = f"Exception caught : {error}"
50
+ print(format_exc())
51
+
52
+ await context.send_activity(MessageFactory.text(error_message))
53
+
54
+ # Send a trace activity
55
+ await context.send_trace_activity(
56
+ "OnTurnError Trace",
57
+ error_message,
58
+ "https://www.botframework.com/schemas/error",
59
+ "TurnError",
60
+ )
61
+
62
+ self.on_turn_error = on_turn_error
63
+
64
+ channel_service_client_factory = (
65
+ channel_service_client_factory
66
+ or RestChannelServiceClientFactory(connection_manager)
67
+ )
68
+
69
+ super().__init__(channel_service_client_factory)
70
+
71
+ async def process(self, request: Request, agent: Agent) -> Optional[Response]:
72
+ if not request:
73
+ raise TypeError("CloudAdapter.process: request can't be None")
74
+ if not agent:
75
+ raise TypeError("CloudAdapter.process: agent can't be None")
76
+
77
+ if request.method == "POST":
78
+ # Deserialize the incoming Activity
79
+ if "application/json" in request.headers["Content-Type"]:
80
+ body = await request.json()
81
+ else:
82
+ raise HTTPUnsupportedMediaType()
83
+
84
+ activity: Activity = Activity.model_validate(body)
85
+ claims_identity: ClaimsIdentity = request.get("claims_identity")
86
+
87
+ # A POST request must contain an Activity
88
+ if (
89
+ not activity.type
90
+ or not activity.conversation
91
+ or not activity.conversation.id
92
+ ):
93
+ raise HTTPBadRequest
94
+
95
+ try:
96
+ # Process the inbound activity with the agent
97
+ invoke_response = await self.process_activity(
98
+ claims_identity, activity, agent.on_turn
99
+ )
100
+
101
+ if (
102
+ activity.type == "invoke"
103
+ or activity.delivery_mode == DeliveryModes.expect_replies
104
+ ):
105
+ # Invoke and ExpectReplies cannot be performed async, the response must be written before the calling thread is released.
106
+ return json_response(
107
+ data=invoke_response.body, status=invoke_response.status
108
+ )
109
+
110
+ return Response(status=202)
111
+ except PermissionError:
112
+ raise HTTPUnauthorized
113
+ else:
114
+ raise HTTPMethodNotAllowed
@@ -0,0 +1,62 @@
1
+ import functools
2
+ from aiohttp.web import Request, middleware, json_response
3
+
4
+ from microsoft.agents.hosting.core.authorization import (
5
+ AgentAuthConfiguration,
6
+ JwtTokenValidator,
7
+ )
8
+
9
+
10
+ @middleware
11
+ async def jwt_authorization_middleware(request: Request, handler):
12
+ auth_config: AgentAuthConfiguration = request.app["agent_configuration"]
13
+ token_validator = JwtTokenValidator(auth_config)
14
+ auth_header = request.headers.get("Authorization")
15
+ if auth_header:
16
+ # Extract the token from the Authorization header
17
+ token = auth_header.split(" ")[1]
18
+ try:
19
+ claims = token_validator.validate_token(token)
20
+ request["claims_identity"] = claims
21
+ except ValueError as e:
22
+ print(f"JWT validation error: {e}")
23
+ return json_response({"error": str(e)}, status=401)
24
+ else:
25
+ if not auth_config.CLIENT_ID:
26
+ # TODO: Refine anonymous strategy
27
+ request["claims_identity"] = token_validator.get_anonymous_claims()
28
+ else:
29
+ return json_response(
30
+ {"error": "Authorization header not found"}, status=401
31
+ )
32
+
33
+ return await handler(request)
34
+
35
+
36
+ def jwt_authorization_decorator(func):
37
+ @functools.wraps(func)
38
+ async def wrapper(request):
39
+ auth_config: AgentAuthConfiguration = request.app["agent_configuration"]
40
+ token_validator = JwtTokenValidator(auth_config)
41
+ auth_header = request.headers.get("Authorization")
42
+ if auth_header:
43
+ # Extract the token from the Authorization header
44
+ token = auth_header.split(" ")[1]
45
+ try:
46
+ claims = token_validator.validate_token(token)
47
+ request["claims_identity"] = claims
48
+ except ValueError as e:
49
+ print(f"JWT validation error: {e}")
50
+ return json_response({"error": str(e)}, status=401)
51
+ else:
52
+ if not auth_config.CLIENT_ID:
53
+ # TODO: Refine anonymous strategy
54
+ request["claims_identity"] = token_validator.get_anonymous_claims()
55
+ else:
56
+ return json_response(
57
+ {"error": "Authorization header not found"}, status=401
58
+ )
59
+
60
+ return await func(request)
61
+
62
+ return wrapper
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: microsoft-agents-hosting-aiohttp
3
+ Version: 0.0.0
4
+ Summary: Integration library for Microsoft Agents with aiohttp
5
+ Author: Microsoft Corporation
6
+ Project-URL: Homepage, https://github.com/microsoft/Agents
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.9
11
+ Requires-Dist: microsoft-agents-hosting-core==0.0.0
12
+ Requires-Dist: aiohttp>=3.11.11
13
+ Dynamic: requires-dist
@@ -0,0 +1,18 @@
1
+ pyproject.toml
2
+ setup.py
3
+ microsoft/agents/hosting/aiohttp/__init__.py
4
+ microsoft/agents/hosting/aiohttp/_start_agent_process.py
5
+ microsoft/agents/hosting/aiohttp/agent_http_adapter.py
6
+ microsoft/agents/hosting/aiohttp/channel_service_route_table.py
7
+ microsoft/agents/hosting/aiohttp/cloud_adapter.py
8
+ microsoft/agents/hosting/aiohttp/jwt_authorization_middleware.py
9
+ microsoft/agents/hosting/aiohttp/app/__init__.py
10
+ microsoft/agents/hosting/aiohttp/app/streaming/__init__.py
11
+ microsoft/agents/hosting/aiohttp/app/streaming/citation.py
12
+ microsoft/agents/hosting/aiohttp/app/streaming/citation_util.py
13
+ microsoft/agents/hosting/aiohttp/app/streaming/streaming_response.py
14
+ microsoft_agents_hosting_aiohttp.egg-info/PKG-INFO
15
+ microsoft_agents_hosting_aiohttp.egg-info/SOURCES.txt
16
+ microsoft_agents_hosting_aiohttp.egg-info/dependency_links.txt
17
+ microsoft_agents_hosting_aiohttp.egg-info/requires.txt
18
+ microsoft_agents_hosting_aiohttp.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ microsoft-agents-hosting-core==0.0.0
2
+ aiohttp>=3.11.11
@@ -0,0 +1,18 @@
1
+ [build-system]
2
+ requires = ["setuptools"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "microsoft-agents-hosting-aiohttp"
7
+ dynamic = ["version", "dependencies"]
8
+ description = "Integration library for Microsoft Agents with aiohttp"
9
+ authors = [{name = "Microsoft Corporation"}]
10
+ requires-python = ">=3.9"
11
+ classifiers = [
12
+ "Programming Language :: Python :: 3",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Operating System :: OS Independent",
15
+ ]
16
+
17
+ [project.urls]
18
+ "Homepage" = "https://github.com/microsoft/Agents"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,12 @@
1
+ from os import environ
2
+ from setuptools import setup
3
+
4
+ package_version = environ.get("PackageVersion", "0.0.0")
5
+
6
+ setup(
7
+ version=package_version,
8
+ install_requires=[
9
+ f"microsoft-agents-hosting-core=={package_version}",
10
+ "aiohttp>=3.11.11",
11
+ ],
12
+ )