microsoft-agents-hosting-core 0.7.0.dev0__py3-none-any.whl → 0.7.0.dev4__py3-none-any.whl

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.
@@ -10,6 +10,15 @@ from .middleware_set import Middleware
10
10
  from .rest_channel_service_client_factory import RestChannelServiceClientFactory
11
11
  from .turn_context import TurnContext
12
12
 
13
+ # HTTP abstractions
14
+ from .http import (
15
+ HttpRequestProtocol,
16
+ HttpResponse,
17
+ HttpResponseFactory,
18
+ HttpAdapterBase,
19
+ ChannelServiceRoutes,
20
+ )
21
+
13
22
  # Application Style
14
23
  from .app._type_defs import RouteHandler, RouteSelector, StateT
15
24
  from .app.agent_application import AgentApplication
@@ -20,6 +29,13 @@ from .app.query import Query
20
29
  from .app._routes import _Route, _RouteList, RouteRank
21
30
  from .app.typing_indicator import TypingIndicator
22
31
 
32
+ # App Streaming
33
+ from .app.streaming import (
34
+ Citation,
35
+ CitationUtil,
36
+ StreamingResponse,
37
+ )
38
+
23
39
  # App Auth
24
40
  from .app.oauth import (
25
41
  Authorization,
@@ -99,6 +115,11 @@ __all__ = [
99
115
  "Middleware",
100
116
  "RestChannelServiceClientFactory",
101
117
  "TurnContext",
118
+ "HttpRequestProtocol",
119
+ "HttpResponse",
120
+ "HttpResponseFactory",
121
+ "HttpAdapterBase",
122
+ "ChannelServiceRoutes",
102
123
  "AgentApplication",
103
124
  "ApplicationError",
104
125
  "ApplicationOptions",
@@ -108,6 +129,9 @@ __all__ = [
108
129
  "Route",
109
130
  "RouteHandler",
110
131
  "TypingIndicator",
132
+ "Citation",
133
+ "CitationUtil",
134
+ "StreamingResponse",
111
135
  "ConversationState",
112
136
  "state",
113
137
  "State",
@@ -0,0 +1,14 @@
1
+ # Copyright (c) Microsoft Corporation. All rights reserved.
2
+ # Licensed under the MIT License.
3
+
4
+ """Streaming response utilities."""
5
+
6
+ from .citation import Citation
7
+ from .citation_util import CitationUtil
8
+ from .streaming_response import StreamingResponse
9
+
10
+ __all__ = [
11
+ "Citation",
12
+ "CitationUtil",
13
+ "StreamingResponse",
14
+ ]
@@ -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,411 @@
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
+
8
+ from microsoft_agents.activity import (
9
+ Activity,
10
+ Entity,
11
+ Attachment,
12
+ Channels,
13
+ ClientCitation,
14
+ DeliveryModes,
15
+ SensitivityUsageInfo,
16
+ )
17
+
18
+ if TYPE_CHECKING:
19
+ from microsoft_agents.hosting.core.turn_context import TurnContext
20
+
21
+ from .citation import Citation
22
+ from .citation_util import CitationUtil
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class StreamingResponse:
28
+ """
29
+ A helper class for streaming responses to the client.
30
+
31
+ This class is used to send a series of updates to the client in a single response.
32
+ The expected sequence of calls is:
33
+
34
+ `queue_informative_update()`, `queue_text_chunk()`, `queue_text_chunk()`, ..., `end_stream()`.
35
+
36
+ Once `end_stream()` is called, the stream is considered ended and no further updates can be sent.
37
+ """
38
+
39
+ def __init__(self, context: "TurnContext"):
40
+ """
41
+ Creates a new StreamingResponse instance.
42
+
43
+ Args:
44
+ context: Context for the current turn of conversation with the user.
45
+ """
46
+ self._context = context
47
+ self._sequence_number = 1
48
+ self._stream_id: Optional[str] = None
49
+ self._message = ""
50
+ self._attachments: Optional[List[Attachment]] = None
51
+ self._ended = False
52
+ self._cancelled = False
53
+
54
+ # Queue for outgoing activities
55
+ self._queue: List[Callable[[], Activity]] = []
56
+ self._queue_sync: Optional[asyncio.Task] = None
57
+ self._chunk_queued = False
58
+
59
+ # Powered by AI feature flags
60
+ self._enable_feedback_loop = False
61
+ self._feedback_loop_type: Optional[Literal["default", "custom"]] = None
62
+ self._enable_generated_by_ai_label = False
63
+ self._citations: Optional[List[ClientCitation]] = []
64
+ self._sensitivity_label: Optional[SensitivityUsageInfo] = None
65
+
66
+ # Channel information
67
+ self._is_streaming_channel: bool = False
68
+ self._channel_id: Channels = None
69
+ self._interval: float = 0.1 # Default interval for sending updates
70
+ self._set_defaults(context)
71
+
72
+ @property
73
+ def stream_id(self) -> Optional[str]:
74
+ """
75
+ Gets the stream ID of the current response.
76
+ Assigned after the initial update is sent.
77
+ """
78
+ return self._stream_id
79
+
80
+ @property
81
+ def citations(self) -> Optional[List[ClientCitation]]:
82
+ """Gets the citations of the current response."""
83
+ return self._citations
84
+
85
+ @property
86
+ def updates_sent(self) -> int:
87
+ """Gets the number of updates sent for the stream."""
88
+ return self._sequence_number - 1
89
+
90
+ def queue_informative_update(self, text: str) -> None:
91
+ """
92
+ Queues an informative update to be sent to the client.
93
+
94
+ Args:
95
+ text: Text of the update to send.
96
+ """
97
+ if not self._is_streaming_channel:
98
+ return
99
+
100
+ if self._ended:
101
+ raise RuntimeError("The stream has already ended.")
102
+
103
+ # Queue a typing activity
104
+ def create_activity():
105
+ activity = Activity(
106
+ type="typing",
107
+ text=text,
108
+ entities=[
109
+ Entity(
110
+ type="streaminfo",
111
+ stream_type="informative",
112
+ stream_sequence=self._sequence_number,
113
+ )
114
+ ],
115
+ )
116
+ self._sequence_number += 1
117
+ return activity
118
+
119
+ self._queue_activity(create_activity)
120
+
121
+ def queue_text_chunk(
122
+ self, text: str, citations: Optional[List[Citation]] = None
123
+ ) -> None:
124
+ """
125
+ Queues a chunk of partial message text to be sent to the client.
126
+
127
+ The text will be sent as quickly as possible to the client.
128
+ Chunks may be combined before delivery to the client.
129
+
130
+ Args:
131
+ text: Partial text of the message to send.
132
+ citations: Citations to be included in the message.
133
+ """
134
+ if self._cancelled:
135
+ return
136
+ if self._ended:
137
+ raise RuntimeError("The stream has already ended.")
138
+
139
+ # Update full message text
140
+ self._message += text
141
+
142
+ # If there are citations, modify the content so that the sources are numbers instead of [doc1], [doc2], etc.
143
+ self._message = CitationUtil.format_citations_response(self._message)
144
+
145
+ # Queue the next chunk
146
+ self._queue_next_chunk()
147
+
148
+ async def end_stream(self) -> None:
149
+ """
150
+ Ends the stream by sending the final message to the client.
151
+ """
152
+ if self._ended:
153
+ raise RuntimeError("The stream has already ended.")
154
+
155
+ # Queue final message
156
+ self._ended = True
157
+ self._queue_next_chunk()
158
+
159
+ # Wait for the queue to drain
160
+ await self.wait_for_queue()
161
+
162
+ def set_attachments(self, attachments: List[Attachment]) -> None:
163
+ """
164
+ Sets the attachments to attach to the final chunk.
165
+
166
+ Args:
167
+ attachments: List of attachments.
168
+ """
169
+ self._attachments = attachments
170
+
171
+ def set_sensitivity_label(self, sensitivity_label: SensitivityUsageInfo) -> None:
172
+ """
173
+ Sets the sensitivity label to attach to the final chunk.
174
+
175
+ Args:
176
+ sensitivity_label: The sensitivity label.
177
+ """
178
+ self._sensitivity_label = sensitivity_label
179
+
180
+ def set_citations(self, citations: List[Citation]) -> None:
181
+ """
182
+ Sets the citations for the full message.
183
+
184
+ Args:
185
+ citations: Citations to be included in the message.
186
+ """
187
+ if citations:
188
+ if not self._citations:
189
+ self._citations = []
190
+
191
+ curr_pos = len(self._citations)
192
+
193
+ for citation in citations:
194
+ client_citation = ClientCitation(
195
+ type="Claim",
196
+ position=curr_pos + 1,
197
+ appearance={
198
+ "type": "DigitalDocument",
199
+ "name": citation.title or f"Document #{curr_pos + 1}",
200
+ "abstract": CitationUtil.snippet(citation.content, 477),
201
+ },
202
+ )
203
+ curr_pos += 1
204
+ self._citations.append(client_citation)
205
+
206
+ def set_feedback_loop(self, enable_feedback_loop: bool) -> None:
207
+ """
208
+ Sets the Feedback Loop in Teams that allows a user to
209
+ give thumbs up or down to a response.
210
+ Default is False.
211
+
212
+ Args:
213
+ enable_feedback_loop: If true, the feedback loop is enabled.
214
+ """
215
+ self._enable_feedback_loop = enable_feedback_loop
216
+
217
+ def set_feedback_loop_type(
218
+ self, feedback_loop_type: Literal["default", "custom"]
219
+ ) -> None:
220
+ """
221
+ Sets the type of UI to use for the feedback loop.
222
+
223
+ Args:
224
+ feedback_loop_type: The type of the feedback loop.
225
+ """
226
+ self._feedback_loop_type = feedback_loop_type
227
+
228
+ def set_generated_by_ai_label(self, enable_generated_by_ai_label: bool) -> None:
229
+ """
230
+ Sets the Generated by AI label in Teams.
231
+ Default is False.
232
+
233
+ Args:
234
+ enable_generated_by_ai_label: If true, the label is added.
235
+ """
236
+ self._enable_generated_by_ai_label = enable_generated_by_ai_label
237
+
238
+ def get_message(self) -> str:
239
+ """
240
+ Returns the most recently streamed message.
241
+ """
242
+ return self._message
243
+
244
+ async def wait_for_queue(self) -> None:
245
+ """
246
+ Waits for the outgoing activity queue to be empty.
247
+ """
248
+ if self._queue_sync:
249
+ await self._queue_sync
250
+
251
+ def _set_defaults(self, context: "TurnContext"):
252
+ if Channels.ms_teams == context.activity.channel_id.channel:
253
+ self._is_streaming_channel = True
254
+ self._interval = 1.0
255
+ elif Channels.direct_line == context.activity.channel_id.channel:
256
+ self._is_streaming_channel = True
257
+ self._interval = 0.5
258
+ elif context.activity.delivery_mode == DeliveryModes.stream:
259
+ self._is_streaming_channel = True
260
+ self._interval = 0.1
261
+
262
+ self._channel_id = context.activity.channel_id
263
+
264
+ def _queue_next_chunk(self) -> None:
265
+ """
266
+ Queues the next chunk of text to be sent to the client.
267
+ """
268
+ # Are we already waiting to send a chunk?
269
+ if self._chunk_queued:
270
+ return
271
+
272
+ # Queue a chunk of text to be sent
273
+ self._chunk_queued = True
274
+
275
+ def create_activity():
276
+ self._chunk_queued = False
277
+ if self._ended:
278
+ # Send final message
279
+ activity = Activity(
280
+ type="message",
281
+ text=self._message or "end stream response",
282
+ attachments=self._attachments or [],
283
+ entities=[
284
+ Entity(
285
+ type="streaminfo",
286
+ stream_id=self._stream_id,
287
+ stream_type="final",
288
+ stream_sequence=self._sequence_number,
289
+ )
290
+ ],
291
+ )
292
+ elif self._is_streaming_channel:
293
+ # Send typing activity
294
+ activity = Activity(
295
+ type="typing",
296
+ text=self._message,
297
+ entities=[
298
+ Entity(
299
+ type="streaminfo",
300
+ stream_type="streaming",
301
+ stream_sequence=self._sequence_number,
302
+ )
303
+ ],
304
+ )
305
+ else:
306
+ return
307
+ self._sequence_number += 1
308
+ return activity
309
+
310
+ self._queue_activity(create_activity)
311
+
312
+ def _queue_activity(self, factory: Callable[[], Activity]) -> None:
313
+ """
314
+ Queues an activity to be sent to the client.
315
+ """
316
+ self._queue.append(factory)
317
+
318
+ # If there's no sync in progress, start one
319
+ if not self._queue_sync:
320
+ self._queue_sync = asyncio.create_task(self._drain_queue())
321
+
322
+ async def _drain_queue(self) -> None:
323
+ """
324
+ Sends any queued activities to the client until the queue is empty.
325
+ """
326
+ try:
327
+ logger.debug(f"Draining queue with {len(self._queue)} activities.")
328
+ while self._queue:
329
+ factory = self._queue.pop(0)
330
+ activity = factory()
331
+ if activity:
332
+ await self._send_activity(activity)
333
+ except Exception as err:
334
+ if (
335
+ "403" in str(err)
336
+ and self._context.activity.channel_id == Channels.ms_teams
337
+ ):
338
+ logger.warning("Teams channel stopped the stream.")
339
+ self._cancelled = True
340
+ else:
341
+ logger.error(
342
+ f"Error occurred when sending activity while streaming: {err}"
343
+ )
344
+ raise
345
+ finally:
346
+ self._queue_sync = None
347
+
348
+ async def _send_activity(self, activity: Activity) -> None:
349
+ """
350
+ Sends an activity to the client and saves the stream ID returned.
351
+
352
+ Args:
353
+ activity: The activity to send.
354
+ """
355
+
356
+ streaminfo_entity = None
357
+
358
+ if not activity.entities:
359
+ streaminfo_entity = Entity(type="streaminfo")
360
+ activity.entities = [streaminfo_entity]
361
+ else:
362
+ for entity in activity.entities:
363
+ if hasattr(entity, "type") and entity.type == "streaminfo":
364
+ streaminfo_entity = entity
365
+ break
366
+
367
+ if not streaminfo_entity:
368
+ # If no streaminfo entity exists, create one
369
+ streaminfo_entity = Entity(type="streaminfo")
370
+ activity.entities.append(streaminfo_entity)
371
+
372
+ # Set activity ID to the assigned stream ID
373
+ if self._stream_id:
374
+ activity.id = self._stream_id
375
+ streaminfo_entity.stream_id = self._stream_id
376
+
377
+ if self._citations and len(self._citations) > 0 and not self._ended:
378
+ # Filter out the citations unused in content.
379
+ curr_citations = CitationUtil.get_used_citations(
380
+ self._message, self._citations
381
+ )
382
+ if curr_citations:
383
+ activity.entities.append(
384
+ Entity(
385
+ type="https://schema.org/Message",
386
+ schema_type="Message",
387
+ context="https://schema.org",
388
+ id="",
389
+ citation=curr_citations,
390
+ )
391
+ )
392
+
393
+ # Add in Powered by AI feature flags
394
+ if self._ended:
395
+ if self._enable_feedback_loop and self._feedback_loop_type:
396
+ # Add feedback loop to streaminfo entity
397
+ streaminfo_entity.feedback_loop = {"type": self._feedback_loop_type}
398
+ else:
399
+ # Add feedback loop enabled to streaminfo entity
400
+ streaminfo_entity.feedback_loop_enabled = self._enable_feedback_loop
401
+ # Add in Generated by AI
402
+ if self._enable_generated_by_ai_label:
403
+ activity.add_ai_metadata(self._citations, self._sensitivity_label)
404
+
405
+ # Send activity
406
+ response = await self._context.send_activity(activity)
407
+ await asyncio.sleep(self._interval)
408
+
409
+ # Save assigned stream ID
410
+ if not self._stream_id and response:
411
+ self._stream_id = response.id
@@ -341,6 +341,19 @@ class ChannelServiceAdapter(ChannelAdapter, ABC):
341
341
  await connector_client.close()
342
342
  await user_token_client.close()
343
343
 
344
+ def _resolve_if_connector_client_is_needed(self, activity: Activity) -> bool:
345
+ """Determine if a connector client is needed based on the activity's delivery mode and service URL.
346
+
347
+ :param activity: The activity to evaluate.
348
+ :type activity: :class:`microsoft_agents.activity.Activity`
349
+ """
350
+ if activity.delivery_mode in [
351
+ DeliveryModes.expect_replies,
352
+ DeliveryModes.stream,
353
+ ]:
354
+ return False
355
+ return True
356
+
344
357
  async def process_activity(
345
358
  self,
346
359
  claims_identity: ClaimsIdentity,
@@ -403,21 +416,24 @@ class ChannelServiceAdapter(ChannelAdapter, ABC):
403
416
  context.turn_state[self.USER_TOKEN_CLIENT_KEY] = user_token_client
404
417
 
405
418
  # Create the connector client to use for outbound requests.
406
- connector_client: ConnectorClient = (
407
- await self._channel_service_client_factory.create_connector_client(
408
- context,
409
- claims_identity,
410
- activity.service_url,
411
- outgoing_audience,
412
- scopes,
413
- use_anonymous_auth_callback,
419
+ connector_client: Optional[ConnectorClient] = None
420
+ if self._resolve_if_connector_client_is_needed(activity):
421
+ connector_client = (
422
+ await self._channel_service_client_factory.create_connector_client(
423
+ context,
424
+ claims_identity,
425
+ activity.service_url,
426
+ outgoing_audience,
427
+ scopes,
428
+ use_anonymous_auth_callback,
429
+ )
414
430
  )
415
- )
416
- context.turn_state[self._AGENT_CONNECTOR_CLIENT_KEY] = connector_client
431
+ context.turn_state[self._AGENT_CONNECTOR_CLIENT_KEY] = connector_client
417
432
 
418
433
  await self.run_pipeline(context, callback)
419
434
 
420
- await connector_client.close()
435
+ if connector_client:
436
+ await connector_client.close()
421
437
  await user_token_client.close()
422
438
 
423
439
  # If there are any results they will have been left on the TurnContext.
@@ -0,0 +1,17 @@
1
+ # Copyright (c) Microsoft Corporation. All rights reserved.
2
+ # Licensed under the MIT License.
3
+
4
+ """HTTP abstractions for framework-agnostic adapter implementations."""
5
+
6
+ from ._http_request_protocol import HttpRequestProtocol
7
+ from ._http_response import HttpResponse, HttpResponseFactory
8
+ from ._http_adapter_base import HttpAdapterBase
9
+ from ._channel_service_routes import ChannelServiceRoutes
10
+
11
+ __all__ = [
12
+ "HttpRequestProtocol",
13
+ "HttpResponse",
14
+ "HttpResponseFactory",
15
+ "HttpAdapterBase",
16
+ "ChannelServiceRoutes",
17
+ ]
@@ -0,0 +1,202 @@
1
+ # Copyright (c) Microsoft Corporation. All rights reserved.
2
+ # Licensed under the MIT License.
3
+
4
+ """Channel service route definitions (framework-agnostic logic)."""
5
+
6
+ from typing import Type, List, Union
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
+ from ._http_request_protocol import HttpRequestProtocol
18
+
19
+
20
+ class ChannelServiceRoutes:
21
+ """Defines the Channel Service API routes and their handlers.
22
+
23
+ This class provides framework-agnostic route logic that can be
24
+ adapted to different web frameworks (aiohttp, FastAPI, etc.).
25
+ """
26
+
27
+ def __init__(self, handler: ChannelApiHandlerProtocol, base_url: str = ""):
28
+ """Initialize channel service routes.
29
+
30
+ Args:
31
+ handler: The handler that implements the Channel API protocol.
32
+ base_url: Optional base URL prefix for all routes.
33
+ """
34
+ self.handler = handler
35
+ self.base_url = base_url
36
+
37
+ @staticmethod
38
+ async def deserialize_from_body(
39
+ request: HttpRequestProtocol, target_model: Type[AgentsModel]
40
+ ) -> AgentsModel:
41
+ """Deserialize request body to target model."""
42
+ content_type = request.headers.get("Content-Type", "")
43
+ if "application/json" not in content_type:
44
+ raise ValueError("Content-Type must be application/json")
45
+
46
+ body = await request.json()
47
+ return target_model.model_validate(body)
48
+
49
+ @staticmethod
50
+ def serialize_model(model_or_list: Union[AgentsModel, List[AgentsModel]]) -> dict:
51
+ """Serialize model or list of models to JSON-compatible dict."""
52
+ if isinstance(model_or_list, AgentsModel):
53
+ return model_or_list.model_dump(
54
+ mode="json", exclude_unset=True, by_alias=True
55
+ )
56
+ else:
57
+ return [
58
+ model.model_dump(mode="json", exclude_unset=True, by_alias=True)
59
+ for model in model_or_list
60
+ ]
61
+
62
+ # Route handler methods
63
+ async def send_to_conversation(self, request: HttpRequestProtocol) -> dict:
64
+ """Handle POST /v3/conversations/{conversation_id}/activities."""
65
+ activity = await self.deserialize_from_body(request, Activity)
66
+ conversation_id = request.get_path_param("conversation_id")
67
+ result = await self.handler.on_send_to_conversation(
68
+ request.get_claims_identity(),
69
+ conversation_id,
70
+ activity,
71
+ )
72
+ return self.serialize_model(result)
73
+
74
+ async def reply_to_activity(self, request: HttpRequestProtocol) -> dict:
75
+ """Handle POST /v3/conversations/{conversation_id}/activities/{activity_id}."""
76
+ activity = await self.deserialize_from_body(request, Activity)
77
+ conversation_id = request.get_path_param("conversation_id")
78
+ activity_id = request.get_path_param("activity_id")
79
+ result = await self.handler.on_reply_to_activity(
80
+ request.get_claims_identity(),
81
+ conversation_id,
82
+ activity_id,
83
+ activity,
84
+ )
85
+ return self.serialize_model(result)
86
+
87
+ async def update_activity(self, request: HttpRequestProtocol) -> dict:
88
+ """Handle PUT /v3/conversations/{conversation_id}/activities/{activity_id}."""
89
+ activity = await self.deserialize_from_body(request, Activity)
90
+ conversation_id = request.get_path_param("conversation_id")
91
+ activity_id = request.get_path_param("activity_id")
92
+ result = await self.handler.on_update_activity(
93
+ request.get_claims_identity(),
94
+ conversation_id,
95
+ activity_id,
96
+ activity,
97
+ )
98
+ return self.serialize_model(result)
99
+
100
+ async def delete_activity(self, request: HttpRequestProtocol) -> None:
101
+ """Handle DELETE /v3/conversations/{conversation_id}/activities/{activity_id}."""
102
+ conversation_id = request.get_path_param("conversation_id")
103
+ activity_id = request.get_path_param("activity_id")
104
+ await self.handler.on_delete_activity(
105
+ request.get_claims_identity(),
106
+ conversation_id,
107
+ activity_id,
108
+ )
109
+
110
+ async def get_activity_members(self, request: HttpRequestProtocol) -> dict:
111
+ """Handle GET /v3/conversations/{conversation_id}/activities/{activity_id}/members."""
112
+ conversation_id = request.get_path_param("conversation_id")
113
+ activity_id = request.get_path_param("activity_id")
114
+ result = await self.handler.on_get_activity_members(
115
+ request.get_claims_identity(),
116
+ conversation_id,
117
+ activity_id,
118
+ )
119
+ return self.serialize_model(result)
120
+
121
+ async def create_conversation(self, request: HttpRequestProtocol) -> dict:
122
+ """Handle POST /."""
123
+ conversation_parameters = await self.deserialize_from_body(
124
+ request, ConversationParameters
125
+ )
126
+ result = await self.handler.on_create_conversation(
127
+ request.get_claims_identity(), conversation_parameters
128
+ )
129
+ return self.serialize_model(result)
130
+
131
+ async def get_conversations(self, request: HttpRequestProtocol) -> dict:
132
+ """Handle GET /."""
133
+ # TODO: continuation token? conversation_id?
134
+ result = await self.handler.on_get_conversations(
135
+ request.get_claims_identity(), None
136
+ )
137
+ return self.serialize_model(result)
138
+
139
+ async def get_conversation_members(self, request: HttpRequestProtocol) -> dict:
140
+ """Handle GET /v3/conversations/{conversation_id}/members."""
141
+ conversation_id = request.get_path_param("conversation_id")
142
+ result = await self.handler.on_get_conversation_members(
143
+ request.get_claims_identity(),
144
+ conversation_id,
145
+ )
146
+ return self.serialize_model(result)
147
+
148
+ async def get_conversation_member(self, request: HttpRequestProtocol) -> dict:
149
+ """Handle GET /v3/conversations/{conversation_id}/members/{member_id}."""
150
+ conversation_id = request.get_path_param("conversation_id")
151
+ member_id = request.get_path_param("member_id")
152
+ result = await self.handler.on_get_conversation_member(
153
+ request.get_claims_identity(),
154
+ member_id,
155
+ conversation_id,
156
+ )
157
+ return self.serialize_model(result)
158
+
159
+ async def get_conversation_paged_members(
160
+ self, request: HttpRequestProtocol
161
+ ) -> dict:
162
+ """Handle GET /v3/conversations/{conversation_id}/pagedmembers."""
163
+ conversation_id = request.get_path_param("conversation_id")
164
+ # TODO: continuation token? page size?
165
+ result = await self.handler.on_get_conversation_paged_members(
166
+ request.get_claims_identity(),
167
+ conversation_id,
168
+ )
169
+ return self.serialize_model(result)
170
+
171
+ async def delete_conversation_member(self, request: HttpRequestProtocol) -> dict:
172
+ """Handle DELETE /v3/conversations/{conversation_id}/members/{member_id}."""
173
+ conversation_id = request.get_path_param("conversation_id")
174
+ member_id = request.get_path_param("member_id")
175
+ result = await self.handler.on_delete_conversation_member(
176
+ request.get_claims_identity(),
177
+ conversation_id,
178
+ member_id,
179
+ )
180
+ return self.serialize_model(result)
181
+
182
+ async def send_conversation_history(self, request: HttpRequestProtocol) -> dict:
183
+ """Handle POST /v3/conversations/{conversation_id}/activities/history."""
184
+ conversation_id = request.get_path_param("conversation_id")
185
+ transcript = await self.deserialize_from_body(request, Transcript)
186
+ result = await self.handler.on_send_conversation_history(
187
+ request.get_claims_identity(),
188
+ conversation_id,
189
+ transcript,
190
+ )
191
+ return self.serialize_model(result)
192
+
193
+ async def upload_attachment(self, request: HttpRequestProtocol) -> dict:
194
+ """Handle POST /v3/conversations/{conversation_id}/attachments."""
195
+ conversation_id = request.get_path_param("conversation_id")
196
+ attachment_data = await self.deserialize_from_body(request, AttachmentData)
197
+ result = await self.handler.on_upload_attachment(
198
+ request.get_claims_identity(),
199
+ conversation_id,
200
+ attachment_data,
201
+ )
202
+ return self.serialize_model(result)
@@ -0,0 +1,136 @@
1
+ # Copyright (c) Microsoft Corporation. All rights reserved.
2
+ # Licensed under the MIT License.
3
+
4
+ """Base HTTP adapter with shared processing logic."""
5
+
6
+ from abc import ABC
7
+ from traceback import format_exc
8
+
9
+ from microsoft_agents.activity import Activity, DeliveryModes
10
+ from microsoft_agents.hosting.core.authorization import ClaimsIdentity, Connections
11
+ from microsoft_agents.hosting.core import (
12
+ Agent,
13
+ ChannelServiceAdapter,
14
+ ChannelServiceClientFactoryBase,
15
+ MessageFactory,
16
+ RestChannelServiceClientFactory,
17
+ TurnContext,
18
+ )
19
+
20
+ from ._http_request_protocol import HttpRequestProtocol
21
+ from ._http_response import HttpResponse, HttpResponseFactory
22
+
23
+
24
+ class HttpAdapterBase(ChannelServiceAdapter, ABC):
25
+ """Base adapter for HTTP-based agent hosting with shared processing logic.
26
+
27
+ This class contains all the common logic for processing HTTP requests
28
+ and can be subclassed by framework-specific adapters (aiohttp, FastAPI, etc).
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ *,
34
+ connection_manager: Connections = None,
35
+ channel_service_client_factory: ChannelServiceClientFactoryBase = None,
36
+ ):
37
+ """Initialize the HTTP adapter.
38
+
39
+ Args:
40
+ connection_manager: Optional connection manager for OAuth.
41
+ channel_service_client_factory: Factory for creating channel service clients.
42
+ """
43
+
44
+ async def on_turn_error(context: TurnContext, error: Exception):
45
+ error_message = f"Exception caught : {error}"
46
+ print(format_exc())
47
+
48
+ await context.send_activity(MessageFactory.text(error_message))
49
+
50
+ # Send a trace activity
51
+ await context.send_trace_activity(
52
+ "OnTurnError Trace",
53
+ error_message,
54
+ "https://www.botframework.com/schemas/error",
55
+ "TurnError",
56
+ )
57
+
58
+ self.on_turn_error = on_turn_error
59
+
60
+ channel_service_client_factory = (
61
+ channel_service_client_factory
62
+ or RestChannelServiceClientFactory(connection_manager)
63
+ )
64
+
65
+ super().__init__(channel_service_client_factory)
66
+
67
+ async def process_request(
68
+ self, request: HttpRequestProtocol, agent: Agent
69
+ ) -> HttpResponse:
70
+ """Process an incoming HTTP request.
71
+
72
+ Args:
73
+ request: The HTTP request to process.
74
+ agent: The agent to handle the request.
75
+
76
+ Returns:
77
+ HttpResponse with the result.
78
+
79
+ Raises:
80
+ TypeError: If request or agent is None.
81
+ """
82
+ if not request:
83
+ raise TypeError("HttpAdapterBase.process_request: request can't be None")
84
+ if not agent:
85
+ raise TypeError("HttpAdapterBase.process_request: agent can't be None")
86
+
87
+ if request.method != "POST":
88
+ return HttpResponseFactory.method_not_allowed()
89
+
90
+ # Deserialize the incoming Activity
91
+ content_type = request.headers.get("Content-Type", "")
92
+ if "application/json" not in content_type:
93
+ return HttpResponseFactory.unsupported_media_type()
94
+
95
+ try:
96
+ body = await request.json()
97
+ except Exception:
98
+ return HttpResponseFactory.bad_request("Invalid JSON")
99
+
100
+ activity: Activity = Activity.model_validate(body)
101
+
102
+ # Get claims identity (default to anonymous if not set by middleware)
103
+ claims_identity: ClaimsIdentity = (
104
+ request.get_claims_identity() or ClaimsIdentity({}, False)
105
+ )
106
+
107
+ # Validate required activity fields
108
+ if (
109
+ not activity.type
110
+ or not activity.conversation
111
+ or not activity.conversation.id
112
+ ):
113
+ return HttpResponseFactory.bad_request(
114
+ "Activity must have type and conversation.id"
115
+ )
116
+
117
+ try:
118
+ # Process the inbound activity with the agent
119
+ invoke_response = await self.process_activity(
120
+ claims_identity, activity, agent.on_turn
121
+ )
122
+
123
+ # Check if we need to return a synchronous response
124
+ if (
125
+ activity.type == "invoke"
126
+ or activity.delivery_mode == DeliveryModes.expect_replies
127
+ ):
128
+ # Invoke and ExpectReplies cannot be performed async
129
+ return HttpResponseFactory.json(
130
+ invoke_response.body, invoke_response.status
131
+ )
132
+
133
+ return HttpResponseFactory.accepted()
134
+
135
+ except PermissionError:
136
+ return HttpResponseFactory.unauthorized()
@@ -0,0 +1,36 @@
1
+ # Copyright (c) Microsoft Corporation. All rights reserved.
2
+ # Licensed under the MIT License.
3
+
4
+ """Protocol for abstracting HTTP request objects across frameworks."""
5
+
6
+ from typing import Protocol, Dict, Any, Optional
7
+
8
+
9
+ class HttpRequestProtocol(Protocol):
10
+ """Protocol for HTTP requests that adapters must implement.
11
+
12
+ This protocol defines the interface that framework-specific request
13
+ adapters must implement to work with the shared HTTP adapter logic.
14
+ """
15
+
16
+ @property
17
+ def method(self) -> str:
18
+ """HTTP method (GET, POST, etc.)."""
19
+ ...
20
+
21
+ @property
22
+ def headers(self) -> Dict[str, str]:
23
+ """Request headers."""
24
+ ...
25
+
26
+ async def json(self) -> Dict[str, Any]:
27
+ """Parse request body as JSON."""
28
+ ...
29
+
30
+ def get_claims_identity(self) -> Optional[Any]:
31
+ """Get claims identity attached by auth middleware."""
32
+ ...
33
+
34
+ def get_path_param(self, name: str) -> str:
35
+ """Get path parameter by name."""
36
+ ...
@@ -0,0 +1,56 @@
1
+ # Copyright (c) Microsoft Corporation. All rights reserved.
2
+ # Licensed under the MIT License.
3
+
4
+ """HTTP response abstraction."""
5
+
6
+ from typing import Any, Optional, Dict
7
+ from dataclasses import dataclass
8
+
9
+
10
+ @dataclass
11
+ class HttpResponse:
12
+ """Framework-agnostic HTTP response."""
13
+
14
+ status_code: int
15
+ body: Optional[Any] = None
16
+ headers: Optional[Dict[str, str]] = None
17
+ content_type: Optional[str] = "application/json"
18
+
19
+
20
+ class HttpResponseFactory:
21
+ """Factory for creating HTTP responses."""
22
+
23
+ @staticmethod
24
+ def ok(body: Any = None) -> HttpResponse:
25
+ """Create 200 OK response."""
26
+ return HttpResponse(status_code=200, body=body)
27
+
28
+ @staticmethod
29
+ def accepted() -> HttpResponse:
30
+ """Create 202 Accepted response."""
31
+ return HttpResponse(status_code=202)
32
+
33
+ @staticmethod
34
+ def json(body: Any, status_code: int = 200) -> HttpResponse:
35
+ """Create JSON response."""
36
+ return HttpResponse(status_code=status_code, body=body)
37
+
38
+ @staticmethod
39
+ def bad_request(message: str = "Bad Request") -> HttpResponse:
40
+ """Create 400 Bad Request response."""
41
+ return HttpResponse(status_code=400, body={"error": message})
42
+
43
+ @staticmethod
44
+ def unauthorized(message: str = "Unauthorized") -> HttpResponse:
45
+ """Create 401 Unauthorized response."""
46
+ return HttpResponse(status_code=401, body={"error": message})
47
+
48
+ @staticmethod
49
+ def method_not_allowed(message: str = "Method Not Allowed") -> HttpResponse:
50
+ """Create 405 Method Not Allowed response."""
51
+ return HttpResponse(status_code=405, body={"error": message})
52
+
53
+ @staticmethod
54
+ def unsupported_media_type(message: str = "Unsupported Media Type") -> HttpResponse:
55
+ """Create 415 Unsupported Media Type response."""
56
+ return HttpResponse(status_code=415, body={"error": message})
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: microsoft-agents-hosting-core
3
- Version: 0.7.0.dev0
3
+ Version: 0.7.0.dev4
4
4
  Summary: Core library for Microsoft Agents
5
5
  Author: Microsoft Corporation
6
6
  License-Expression: MIT
@@ -15,7 +15,7 @@ Classifier: Operating System :: OS Independent
15
15
  Requires-Python: >=3.10
16
16
  Description-Content-Type: text/markdown
17
17
  License-File: LICENSE
18
- Requires-Dist: microsoft-agents-activity==0.7.0.dev0
18
+ Requires-Dist: microsoft-agents-activity==0.7.0.dev4
19
19
  Requires-Dist: pyjwt>=2.10.1
20
20
  Requires-Dist: isodate>=0.6.1
21
21
  Requires-Dist: azure-core>=1.30.0
@@ -1,10 +1,10 @@
1
- microsoft_agents/hosting/core/__init__.py,sha256=jZ0551xg2RnMtHpAMD1AtXyDh0oPUSDYPe_S9QNdbtA,5012
1
+ microsoft_agents/hosting/core/__init__.py,sha256=iPlMZNnfGWwNe52937iqpDx40szMXzcYm_T2E6iFkag,5459
2
2
  microsoft_agents/hosting/core/activity_handler.py,sha256=1hsSmVCnQLS44RK05v8j6mlmV38_JGmJTPR9LkogQuc,27779
3
3
  microsoft_agents/hosting/core/agent.py,sha256=K8v84y8ULP7rbcMKg8LxaM3haAq7f1oHFCLy3AAphQE,574
4
4
  microsoft_agents/hosting/core/card_factory.py,sha256=UDmPEpOk2SpEr9ShN9Q0CiaI_GTD3qjHgkDMOWinW9I,6926
5
5
  microsoft_agents/hosting/core/channel_adapter.py,sha256=MqES9gHGS0nrKBR7u8zLQCsuIksl-hScZy_jg4LTTHo,10669
6
6
  microsoft_agents/hosting/core/channel_api_handler_protocol.py,sha256=RO59hiOYx7flQVWhX6VGvfpFPoKVkdT_la-7WhUl8UM,4506
7
- microsoft_agents/hosting/core/channel_service_adapter.py,sha256=OTF1EFycfBv4sdX2Zt_jPzk80aRHjLhSv5DyU6CEc7Q,20919
7
+ microsoft_agents/hosting/core/channel_service_adapter.py,sha256=L3XQQSnWuF-8_1MAyZTsI7RRn9TfYv0UW1Dj6nyOuAQ,21598
8
8
  microsoft_agents/hosting/core/channel_service_client_factory_base.py,sha256=9LcpxiWMXmWAdGQt6lRzvY9ybF_5mGc5Focd1Z7f9MY,1711
9
9
  microsoft_agents/hosting/core/message_factory.py,sha256=F9QJBF4yBupHXxOW984ZzZomVEG57t9IUnTHwub-lX0,7822
10
10
  microsoft_agents/hosting/core/middleware_set.py,sha256=TBsBs4KwAfKyHlQTlG4bl1y5UjkBzeMDs5w7LNB-Bi4,2585
@@ -40,6 +40,10 @@ microsoft_agents/hosting/core/app/state/conversation_state.py,sha256=LfcSwvhaU0J
40
40
  microsoft_agents/hosting/core/app/state/state.py,sha256=mbNrHuAc7d9vJep3PtExbLFtfKxZoAUX3Jr45GT3SuA,7373
41
41
  microsoft_agents/hosting/core/app/state/temp_state.py,sha256=Oh7K5-uYf50Z-lBXqttSMl2N1lRakktOmjLlIAKcEsM,3501
42
42
  microsoft_agents/hosting/core/app/state/turn_state.py,sha256=rEIRkwBsn3MPbrfKNjX8XqqbF-4THepMXU75KvDsBvM,9868
43
+ microsoft_agents/hosting/core/app/streaming/__init__.py,sha256=LtchDJTb25owxcbDd1sWr0BtGHK-PQboxCX4oNiN89c,329
44
+ microsoft_agents/hosting/core/app/streaming/citation.py,sha256=ZGaMUOWxxoMplwRrkFsjnK7Z12V6rT5odE7qZCu-mP8,498
45
+ microsoft_agents/hosting/core/app/streaming/citation_util.py,sha256=c95c3Y3genmFc0vSXppPaD1-ShFohAV1UABZnyJS_BQ,2478
46
+ microsoft_agents/hosting/core/app/streaming/streaming_response.py,sha256=uwNYaVJ8cghdjHS_Y8ZeOiYnpyeQGHt0rQQlay6a-C8,13799
43
47
  microsoft_agents/hosting/core/authorization/__init__.py,sha256=pOTmTJFS5CMPEcHRadBTgrbWUP5lOIxsPMgTreFq7mw,634
44
48
  microsoft_agents/hosting/core/authorization/access_token_provider_base.py,sha256=Z0nGkfOUSIBvJSFjGGNQEd1-upqZVjT6eSXsqsFa2Cc,1646
45
49
  microsoft_agents/hosting/core/authorization/agent_auth_configuration.py,sha256=meLUO0Mwb9SBr01dLvYS9q3xuZyndCsb_gIx0r7__Sk,2944
@@ -78,6 +82,11 @@ microsoft_agents/hosting/core/connector/teams/__init__.py,sha256=3ZMPGYyZ15EwvfQ
78
82
  microsoft_agents/hosting/core/connector/teams/teams_connector_client.py,sha256=XGQDTYHrA_I9n9JlxGST5eesjsFhz2dnSaMSuyoFnKU,12676
79
83
  microsoft_agents/hosting/core/errors/__init__.py,sha256=kcfwDGDaopWfLyvYsNXM6j4cYt2oGKysjCRDezcVY0A,500
80
84
  microsoft_agents/hosting/core/errors/error_resources.py,sha256=_nyvPQaX3gpi9kziajVdzGWCzMa6JcTntK__zQJlGMo,3922
85
+ microsoft_agents/hosting/core/http/__init__.py,sha256=Jh6lIivBN9YsNaLPzYvzvbhLAwJbeQTSRHtFupfSy-I,532
86
+ microsoft_agents/hosting/core/http/_channel_service_routes.py,sha256=80IWoUDKywUurXnh7vmG0QMxu3sk7W38zgHsw0eJ2DA,8436
87
+ microsoft_agents/hosting/core/http/_http_adapter_base.py,sha256=qRqAwMl1Wsq_A1J6asMMcS9jUGjlLD3HF-0Sioba5IM,4561
88
+ microsoft_agents/hosting/core/http/_http_request_protocol.py,sha256=YTfcp_K1IswAIWVQ5QFBTyX-ssuUo6BChdlPHEh-t4Q,1002
89
+ microsoft_agents/hosting/core/http/_http_response.py,sha256=e_bY0bJJXx--Zb_Yd-f9iDPFbK4bWkFi92a0nq1A6us,1862
81
90
  microsoft_agents/hosting/core/state/__init__.py,sha256=yckKi1wg_86ng-DL9Q3R49QiWKvNjPkVNk6HClWgVrY,208
82
91
  microsoft_agents/hosting/core/state/agent_state.py,sha256=uboptWaC3VrSGTnXIzaO38XUqOT-ITW6EhJxuGMtKWs,13724
83
92
  microsoft_agents/hosting/core/state/state_property_accessor.py,sha256=kpiNnzkZ6el-oRITRbRkk1Faa_CPFxpJQdvSGxIJP70,1392
@@ -93,8 +102,8 @@ microsoft_agents/hosting/core/storage/transcript_info.py,sha256=5VN32j99tshChAff
93
102
  microsoft_agents/hosting/core/storage/transcript_logger.py,sha256=_atDk3CJ05fIVMhlWGNa91IiM9bGLmOhasFko8Lxjhk,8237
94
103
  microsoft_agents/hosting/core/storage/transcript_memory_store.py,sha256=v1Ud9LSs8m5c9_Fa8i49SuAjw80dX1hDciqbRduDEOE,6444
95
104
  microsoft_agents/hosting/core/storage/transcript_store.py,sha256=ka74o0WvI5GhMZcFqSxVdamBhGzZcDZe6VNkG-sMy74,1944
96
- microsoft_agents_hosting_core-0.7.0.dev0.dist-info/licenses/LICENSE,sha256=ws_MuBL-SCEBqPBFl9_FqZkaaydIJmxHrJG2parhU4M,1141
97
- microsoft_agents_hosting_core-0.7.0.dev0.dist-info/METADATA,sha256=kum-N0M9RQKtwhrewQsTH8JkWJXgo1Fqnr5yjD--cmc,9242
98
- microsoft_agents_hosting_core-0.7.0.dev0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
99
- microsoft_agents_hosting_core-0.7.0.dev0.dist-info/top_level.txt,sha256=lWKcT4v6fTA_NgsuHdNvuMjSrkiBMXohn64ApY7Xi8A,17
100
- microsoft_agents_hosting_core-0.7.0.dev0.dist-info/RECORD,,
105
+ microsoft_agents_hosting_core-0.7.0.dev4.dist-info/licenses/LICENSE,sha256=ws_MuBL-SCEBqPBFl9_FqZkaaydIJmxHrJG2parhU4M,1141
106
+ microsoft_agents_hosting_core-0.7.0.dev4.dist-info/METADATA,sha256=p3_XQtKWH-QkdDbgeN6iSZYLZLyRwwH831hIC0TWsj8,9242
107
+ microsoft_agents_hosting_core-0.7.0.dev4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
108
+ microsoft_agents_hosting_core-0.7.0.dev4.dist-info/top_level.txt,sha256=lWKcT4v6fTA_NgsuHdNvuMjSrkiBMXohn64ApY7Xi8A,17
109
+ microsoft_agents_hosting_core-0.7.0.dev4.dist-info/RECORD,,