microsoft-agents-hosting-core 0.4.0.dev14__py3-none-any.whl → 0.4.0.dev16__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.
@@ -213,21 +213,21 @@ class ChannelServiceAdapter(ChannelAdapter, ABC):
213
213
  claims_identity = self.create_claims_identity(agent_app_id)
214
214
  claims_identity.claims[AuthenticationConstants.SERVICE_URL_CLAIM] = service_url
215
215
 
216
- # Create a UserTokenClient instance for the application to use. (For example, in the OAuthPrompt.)
217
- user_token_client: UserTokenClient = (
218
- await self._channel_service_client_factory.create_user_token_client(
219
- claims_identity
220
- )
221
- )
222
-
223
216
  # Create a turn context and run the pipeline.
224
217
  context = self._create_turn_context(
225
218
  claims_identity,
226
219
  None,
227
- user_token_client,
228
220
  callback,
229
221
  )
230
222
 
223
+ # Create a UserTokenClient instance for the application to use. (For example, in the OAuthPrompt.)
224
+ user_token_client: UserTokenClient = (
225
+ await self._channel_service_client_factory.create_user_token_client(
226
+ context, claims_identity
227
+ )
228
+ )
229
+ context.turn_state[self.USER_TOKEN_CLIENT_KEY] = user_token_client
230
+
231
231
  # Create the connector client to use for outbound requests.
232
232
  connector_client: ConnectorClient = (
233
233
  await self._channel_service_client_factory.create_connector_client(
@@ -264,22 +264,21 @@ class ChannelServiceAdapter(ChannelAdapter, ABC):
264
264
  callback: Callable[[TurnContext], Awaitable],
265
265
  ):
266
266
 
267
- # Create a UserTokenClient instance for the application to use. (For example, in the OAuthPrompt.)
268
- user_token_client: UserTokenClient = (
269
- await self._channel_service_client_factory.create_user_token_client(
270
- claims_identity
271
- )
272
- )
273
-
274
267
  # Create a turn context and run the pipeline.
275
268
  context = self._create_turn_context(
276
269
  claims_identity,
277
270
  audience,
278
- user_token_client,
279
271
  callback,
280
272
  activity=continuation_activity,
281
273
  )
282
274
 
275
+ user_token_client: UserTokenClient = (
276
+ await self._channel_service_client_factory.create_user_token_client(
277
+ context, claims_identity
278
+ )
279
+ )
280
+ context.turn_state[self.USER_TOKEN_CLIENT_KEY] = user_token_client
281
+
283
282
  # Create the connector client to use for outbound requests.
284
283
  connector_client: ConnectorClient = (
285
284
  await self._channel_service_client_factory.create_connector_client(
@@ -338,22 +337,22 @@ class ChannelServiceAdapter(ChannelAdapter, ABC):
338
337
  ):
339
338
  use_anonymous_auth_callback = True
340
339
 
341
- # Create a UserTokenClient instance for the OAuth flow.
342
- user_token_client: UserTokenClient = (
343
- await self._channel_service_client_factory.create_user_token_client(
344
- claims_identity, use_anonymous_auth_callback
345
- )
346
- )
347
-
348
340
  # Create a turn context and run the pipeline.
349
341
  context = self._create_turn_context(
350
342
  claims_identity,
351
343
  outgoing_audience,
352
- user_token_client,
353
344
  callback,
354
345
  activity=activity,
355
346
  )
356
347
 
348
+ # Create a UserTokenClient instance for the OAuth flow.
349
+ user_token_client: UserTokenClient = (
350
+ await self._channel_service_client_factory.create_user_token_client(
351
+ context, claims_identity, use_anonymous_auth_callback
352
+ )
353
+ )
354
+ context.turn_state[self.USER_TOKEN_CLIENT_KEY] = user_token_client
355
+
357
356
  # Create the connector client to use for outbound requests.
358
357
  connector_client: ConnectorClient = (
359
358
  await self._channel_service_client_factory.create_connector_client(
@@ -425,14 +424,12 @@ class ChannelServiceAdapter(ChannelAdapter, ABC):
425
424
  self,
426
425
  claims_identity: ClaimsIdentity,
427
426
  oauth_scope: str,
428
- user_token_client: UserTokenClientBase,
429
427
  callback: Callable[[TurnContext], Awaitable],
430
428
  activity: Optional[Activity] = None,
431
429
  ) -> TurnContext:
432
430
  context = TurnContext(self, activity, claims_identity)
433
431
 
434
432
  context.turn_state[self.AGENT_IDENTITY_KEY] = claims_identity
435
- context.turn_state[self.USER_TOKEN_CLIENT_KEY] = user_token_client
436
433
  context.turn_state[self.AGENT_CALLBACK_HANDLER_KEY] = callback
437
434
  context.turn_state[self.CHANNEL_SERVICE_FACTORY_KEY] = (
438
435
  self._channel_service_client_factory
@@ -6,12 +6,14 @@ from microsoft_agents.hosting.core.connector import (
6
6
  ConnectorClientBase,
7
7
  UserTokenClientBase,
8
8
  )
9
+ from microsoft_agents.hosting.core.turn_context import TurnContext
9
10
 
10
11
 
11
12
  class ChannelServiceClientFactoryBase(Protocol):
12
13
  @abstractmethod
13
14
  async def create_connector_client(
14
15
  self,
16
+ context: TurnContext,
15
17
  claims_identity: ClaimsIdentity,
16
18
  service_url: str,
17
19
  audience: str,
@@ -32,7 +34,10 @@ class ChannelServiceClientFactoryBase(Protocol):
32
34
 
33
35
  @abstractmethod
34
36
  async def create_user_token_client(
35
- self, claims_identity: ClaimsIdentity, use_anonymous: bool = False
37
+ self,
38
+ context: TurnContext,
39
+ claims_identity: ClaimsIdentity,
40
+ use_anonymous: bool = False,
36
41
  ) -> UserTokenClientBase:
37
42
  """
38
43
  Creates the appropriate UserTokenClientBase instance.
@@ -122,8 +122,12 @@ class AttachmentsOperations(AttachmentsBase):
122
122
 
123
123
  class ConversationsOperations(ConversationsBase):
124
124
 
125
- def __init__(self, client: ClientSession):
125
+ def __init__(self, client: ClientSession, **kwargs):
126
126
  self.client = client
127
+ self._max_conversation_id_length = kwargs.get("max_conversation_id_length", 200)
128
+
129
+ def _normalize_conversation_id(self, conversation_id: str) -> str:
130
+ return conversation_id[: self._max_conversation_id_length]
127
131
 
128
132
  async def get_conversations(
129
133
  self, continuation_token: Optional[str] = None
@@ -193,11 +197,16 @@ class ConversationsOperations(ConversationsBase):
193
197
  )
194
198
  raise ValueError("conversationId and activityId are required")
195
199
 
200
+ print("\n*3")
201
+ print(conversation_id)
202
+ print("\n*3")
203
+ conversation_id = self._normalize_conversation_id(conversation_id)
196
204
  url = f"v3/conversations/{conversation_id}/activities/{activity_id}"
197
205
 
198
206
  logger.info(
199
207
  f"Replying to activity: {activity_id} in conversation: {conversation_id}. Activity type is {body.type}"
200
208
  )
209
+
201
210
  async with self.client.post(
202
211
  url,
203
212
  json=body.model_dump(
@@ -216,7 +225,8 @@ class ConversationsOperations(ConversationsBase):
216
225
  logger.info(
217
226
  f"Reply to conversation/activity: {result.get('id')}, {activity_id}"
218
227
  )
219
- return ResourceResponse.model_validate(result)
228
+
229
+ return ResourceResponse.model_validate(result)
220
230
 
221
231
  async def send_to_conversation(
222
232
  self, conversation_id: str, body: Activity
@@ -235,6 +245,7 @@ class ConversationsOperations(ConversationsBase):
235
245
  )
236
246
  raise ValueError("conversationId is required")
237
247
 
248
+ conversation_id = self._normalize_conversation_id(conversation_id)
238
249
  url = f"v3/conversations/{conversation_id}/activities"
239
250
 
240
251
  logger.info(
@@ -271,6 +282,7 @@ class ConversationsOperations(ConversationsBase):
271
282
  )
272
283
  raise ValueError("conversationId and activityId are required")
273
284
 
285
+ conversation_id = self._normalize_conversation_id(conversation_id)
274
286
  url = f"v3/conversations/{conversation_id}/activities/{activity_id}"
275
287
 
276
288
  logger.info(
@@ -303,6 +315,7 @@ class ConversationsOperations(ConversationsBase):
303
315
  )
304
316
  raise ValueError("conversationId and activityId are required")
305
317
 
318
+ conversation_id = self._normalize_conversation_id(conversation_id)
306
319
  url = f"v3/conversations/{conversation_id}/activities/{activity_id}"
307
320
 
308
321
  logger.info(
@@ -332,6 +345,7 @@ class ConversationsOperations(ConversationsBase):
332
345
  )
333
346
  raise ValueError("conversationId is required")
334
347
 
348
+ conversation_id = self._normalize_conversation_id(conversation_id)
335
349
  url = f"v3/conversations/{conversation_id}/attachments"
336
350
 
337
351
  # Convert the AttachmentData to a dictionary
@@ -371,6 +385,7 @@ class ConversationsOperations(ConversationsBase):
371
385
  )
372
386
  raise ValueError("conversationId is required")
373
387
 
388
+ conversation_id = self._normalize_conversation_id(conversation_id)
374
389
  url = f"v3/conversations/{conversation_id}/members"
375
390
 
376
391
  logger.info(f"Getting conversation members for conversation: {conversation_id}")
@@ -402,6 +417,7 @@ class ConversationsOperations(ConversationsBase):
402
417
  )
403
418
  raise ValueError("conversationId and memberId are required")
404
419
 
420
+ conversation_id = self._normalize_conversation_id(conversation_id)
405
421
  url = f"v3/conversations/{conversation_id}/members/{member_id}"
406
422
 
407
423
  logger.info(
@@ -434,6 +450,7 @@ class ConversationsOperations(ConversationsBase):
434
450
  )
435
451
  raise ValueError("conversationId and memberId are required")
436
452
 
453
+ conversation_id = self._normalize_conversation_id(conversation_id)
437
454
  url = f"v3/conversations/{conversation_id}/members/{member_id}"
438
455
 
439
456
  logger.info(
@@ -464,6 +481,7 @@ class ConversationsOperations(ConversationsBase):
464
481
  )
465
482
  raise ValueError("conversationId and activityId are required")
466
483
 
484
+ conversation_id = self._normalize_conversation_id(conversation_id)
467
485
  url = f"v3/conversations/{conversation_id}/activities/{activity_id}/members"
468
486
 
469
487
  logger.info(
@@ -507,6 +525,7 @@ class ConversationsOperations(ConversationsBase):
507
525
  if continuation_token is not None:
508
526
  params["continuationToken"] = continuation_token
509
527
 
528
+ conversation_id = self._normalize_conversation_id(conversation_id)
510
529
  url = f"v3/conversations/{conversation_id}/pagedmembers"
511
530
 
512
531
  logger.info(
@@ -540,6 +559,7 @@ class ConversationsOperations(ConversationsBase):
540
559
  )
541
560
  raise ValueError("conversationId is required")
542
561
 
562
+ conversation_id = self._normalize_conversation_id(conversation_id)
543
563
  url = f"v3/conversations/{conversation_id}/activities/history"
544
564
 
545
565
  logger.info(f"Sending conversation history to conversation: {conversation_id}")
@@ -1,4 +1,3 @@
1
- import re
2
1
  from typing import Optional
3
2
  import logging
4
3
 
@@ -33,6 +32,52 @@ class RestChannelServiceClientFactory(ChannelServiceClientFactoryBase):
33
32
  self._token_service_endpoint = token_service_endpoint
34
33
  self._token_service_audience = token_service_audience
35
34
 
35
+ async def _get_agentic_token(self, context: TurnContext, service_url: str) -> str:
36
+ logger.info(
37
+ "Creating connector client for agentic request to service_url: %s",
38
+ service_url,
39
+ )
40
+
41
+ if not context.identity:
42
+ raise ValueError("context.identity is required for agentic activities")
43
+
44
+ connection = self._connection_manager.get_token_provider(
45
+ context.identity, service_url
46
+ )
47
+ if not hasattr(connection, "_msal_configuration"):
48
+ raise TypeError(
49
+ "Connection does not support MSAL configuration for agentic token retrieval"
50
+ )
51
+
52
+ if connection._msal_configuration.ALT_BLUEPRINT_ID:
53
+ logger.debug(
54
+ "Using alternative blueprint ID for agentic token retrieval: %s",
55
+ connection._msal_configuration.ALT_BLUEPRINT_ID,
56
+ )
57
+ connection = self._connection_manager.get_connection(
58
+ connection._msal_configuration.ALT_BLUEPRINT_ID
59
+ )
60
+
61
+ agent_instance_id = context.activity.get_agentic_instance_id()
62
+ if not agent_instance_id:
63
+ raise ValueError("Agent instance ID is required for agentic identity role")
64
+
65
+ if context.activity.recipient.role == RoleTypes.agentic_identity:
66
+ token, _ = await connection.get_agentic_instance_token(agent_instance_id)
67
+ else:
68
+ agentic_user = context.activity.get_agentic_user()
69
+ if not agentic_user:
70
+ raise ValueError("Agentic user is required for agentic user role")
71
+ token = await connection.get_agentic_user_token(
72
+ agent_instance_id,
73
+ agentic_user,
74
+ [AuthenticationConstants.APX_PRODUCTION_SCOPE],
75
+ )
76
+
77
+ if not token:
78
+ raise ValueError("Failed to obtain token for agentic activity")
79
+ return token
80
+
36
81
  async def create_connector_client(
37
82
  self,
38
83
  context: TurnContext,
@@ -42,6 +87,8 @@ class RestChannelServiceClientFactory(ChannelServiceClientFactoryBase):
42
87
  scopes: Optional[list[str]] = None,
43
88
  use_anonymous: bool = False,
44
89
  ) -> ConnectorClientBase:
90
+ if not context or not claims_identity:
91
+ raise TypeError("context and claims_identity are required")
45
92
  if not service_url:
46
93
  raise TypeError(
47
94
  "RestChannelServiceClientFactory.create_connector_client: service_url can't be None or Empty"
@@ -52,50 +99,7 @@ class RestChannelServiceClientFactory(ChannelServiceClientFactoryBase):
52
99
  )
53
100
 
54
101
  if context.activity.is_agentic_request():
55
- logger.info(
56
- "Creating connector client for agentic request to service_url: %s",
57
- service_url,
58
- )
59
-
60
- if not context.identity:
61
- raise ValueError("context.identity is required for agentic activities")
62
-
63
- connection = self._connection_manager.get_token_provider(
64
- context.identity, service_url
65
- )
66
-
67
- # TODO: clean up linter
68
- if connection._msal_configuration.ALT_BLUEPRINT_ID:
69
- logger.debug(
70
- "Using alternative blueprint ID for agentic token retrieval: %s",
71
- connection._msal_configuration.ALT_BLUEPRINT_ID,
72
- )
73
- connection = self._connection_manager.get_connection(
74
- connection._msal_configuration.ALT_BLUEPRINT_ID
75
- )
76
-
77
- agent_instance_id = context.activity.get_agentic_instance_id()
78
- if not agent_instance_id:
79
- raise ValueError(
80
- "Agent instance ID is required for agentic identity role"
81
- )
82
-
83
- if context.activity.recipient.role == RoleTypes.agentic_identity:
84
- token, _ = await connection.get_agentic_instance_token(
85
- agent_instance_id
86
- )
87
- else:
88
- agentic_user = context.activity.get_agentic_user()
89
- if not agentic_user:
90
- raise ValueError("Agentic user is required for agentic user role")
91
- token = await connection.get_agentic_user_token(
92
- agent_instance_id,
93
- agentic_user,
94
- [AuthenticationConstants.APX_PRODUCTION_SCOPE],
95
- )
96
-
97
- if not token:
98
- raise ValueError("Failed to obtain token for agentic activity")
102
+ token = await self._get_agentic_token(context, service_url)
99
103
  else:
100
104
  token_provider: AccessTokenProviderBase = (
101
105
  self._connection_manager.get_token_provider(
@@ -115,18 +119,40 @@ class RestChannelServiceClientFactory(ChannelServiceClientFactoryBase):
115
119
  )
116
120
 
117
121
  async def create_user_token_client(
118
- self, claims_identity: ClaimsIdentity, use_anonymous: bool = False
122
+ self,
123
+ context: TurnContext,
124
+ claims_identity: ClaimsIdentity,
125
+ use_anonymous: bool = False,
119
126
  ) -> UserTokenClient:
127
+ """Create a UserTokenClient for the given context and claims identity.
128
+
129
+ :param context: The TurnContext for the current turn of conversation.
130
+ :param claims_identity: The ClaimsIdentity of the user.
131
+ :param use_anonymous: Whether to use an anonymous token provider.
132
+ """
133
+ if not context or not claims_identity:
134
+ raise ValueError("context and claims_identity are required")
135
+
120
136
  if use_anonymous:
121
137
  return UserTokenClient(endpoint=self._token_service_endpoint, token="")
122
138
 
123
- token_provider = self._connection_manager.get_token_provider(
124
- claims_identity, self._token_service_endpoint
125
- )
139
+ if context.activity.is_agentic_request():
140
+ token = await self._get_agentic_token(context, self._token_service_endpoint)
141
+ else:
142
+ scopes = [f"{self._token_service_audience}/.default"]
143
+
144
+ token_provider = self._connection_manager.get_token_provider(
145
+ claims_identity, self._token_service_endpoint
146
+ )
147
+
148
+ token = await token_provider.get_access_token(
149
+ self._token_service_audience, scopes
150
+ )
151
+
152
+ if not token:
153
+ logger.error("Failed to obtain token for user token client")
154
+ raise ValueError("Failed to obtain token for user token client")
126
155
 
127
- token = await token_provider.get_access_token(
128
- self._token_service_audience, [f"{self._token_service_audience}/.default"]
129
- )
130
156
  return UserTokenClient(
131
157
  endpoint=self._token_service_endpoint,
132
158
  token=token,
@@ -7,8 +7,10 @@ from .transcript_logger import (
7
7
  ConsoleTranscriptLogger,
8
8
  TranscriptLoggerMiddleware,
9
9
  FileTranscriptLogger,
10
+ PagedResult,
10
11
  )
11
12
  from .transcript_store import TranscriptStore
13
+ from .transcript_file_store import FileTranscriptStore
12
14
 
13
15
  __all__ = [
14
16
  "StoreItem",
@@ -21,4 +23,6 @@ __all__ = [
21
23
  "TranscriptLoggerMiddleware",
22
24
  "TranscriptStore",
23
25
  "FileTranscriptLogger",
26
+ "FileTranscriptStore",
27
+ "PagedResult",
24
28
  ]
@@ -0,0 +1,267 @@
1
+ # Copyright (c) Microsoft Corporation. All rights reserved.
2
+ # Licensed under the MIT License.
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import json
8
+ import os
9
+ import re
10
+
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
14
+
15
+ from .transcript_logger import TranscriptLogger
16
+ from .transcript_logger import PagedResult
17
+ from .transcript_info import TranscriptInfo
18
+
19
+ from microsoft_agents.activity import Activity # type: ignore
20
+
21
+
22
+ class FileTranscriptStore(TranscriptLogger):
23
+ """
24
+ Python port of the .NET FileTranscriptStore which creates a single
25
+ `.transcript` file per conversation and appends each Activity as newline-delimited JSON.
26
+
27
+ Layout on disk:
28
+ <root>/<channelId>/<conversationId>.transcript
29
+
30
+ - Each line is a JSON object representing one Activity.
31
+ - Methods are async to match the Agents SDK shape.
32
+
33
+ Notes
34
+ -----
35
+ * Continuation tokens are simple integer byte offsets encoded as strings.
36
+ * Activities are written using UTF-8 with newline separators (JSONL).
37
+ * Filenames are sanitized to avoid path traversal and invalid characters.
38
+
39
+ Inspired by the .NET design for FileTranscriptLogger. See:
40
+ - Microsoft.Bot.Builder FileTranscriptLogger docs (for behavior) [DOTNET]
41
+ - Microsoft.Agents.Storage.Transcript namespace overview [AGENTS]
42
+ """
43
+
44
+ def __init__(self, root_folder: Union[str, Path]) -> None:
45
+ self._root = Path(root_folder).expanduser().resolve()
46
+ self._root.mkdir(parents=True, exist_ok=True)
47
+
48
+ # precompiled regex for safe names (letters, digits, dash, underscore, dot)
49
+ self._safe = re.compile(r"[^A-Za-z0-9._-]+")
50
+
51
+ # -------- Logger surface --------
52
+
53
+ async def log_activity(self, activity: Activity) -> None:
54
+ """
55
+ Asynchronously persist a transcript activity to the file system.
56
+ This method computes the transcript file path based on the activity’s channel
57
+ and conversation identifiers, ensures the directory exists, and appends the
58
+ activity data to the transcript file in JSON format using a background thread.
59
+ If the activity lacks a timestamp, one is assigned prior to serialization.
60
+ :param activity: The activity to log.
61
+ """
62
+ if not activity:
63
+ raise ValueError("Activity is required")
64
+
65
+ channel_id, conversation_id = _get_ids(activity)
66
+ file_path = self._file_path(channel_id, conversation_id)
67
+ file_path.parent.mkdir(parents=True, exist_ok=True)
68
+
69
+ # Ensure a stable timestamp property if absent
70
+ # Write in a background thread to avoid blocking the event loop
71
+ def _write() -> None:
72
+ # Normalize to a dict to ensure json serializable content.
73
+ if not activity.timestamp:
74
+ activity.timestamp = _utc_iso_now()
75
+
76
+ with open(file_path, "a", encoding="utf-8", newline="\n") as f:
77
+ f.write(activity.model_dump_json(exclude_none=True, exclude_unset=True))
78
+ f.write("\n")
79
+
80
+ await asyncio.to_thread(_write)
81
+
82
+ # -------- Store surface --------
83
+
84
+ async def list_transcripts(self, channel_id: str) -> PagedResult[TranscriptInfo]:
85
+ """
86
+ List transcripts (conversations) for a channel.
87
+ :param channel_id: The channel ID to list transcripts for."""
88
+ channel_dir = self._channel_dir(channel_id)
89
+
90
+ def _list() -> List[TranscriptInfo]:
91
+ if not channel_dir.exists():
92
+ return []
93
+ results: List[TranscriptInfo] = []
94
+ for p in channel_dir.glob("*.transcript"):
95
+ # mtime is a reasonable proxy for 'created/updated'
96
+ created = datetime.fromtimestamp(p.stat().st_mtime, tz=timezone.utc)
97
+ results.append(
98
+ TranscriptInfo(
99
+ channel_id=_sanitize(self._safe, channel_id),
100
+ conversation_id=p.stem,
101
+ created_on=created,
102
+ )
103
+ )
104
+ # Sort newest first (consistent, useful default)
105
+ results.sort(key=lambda t: t.created_on, reverse=True)
106
+ return results
107
+
108
+ items = await asyncio.to_thread(_list)
109
+ return PagedResult(items=items, continuation_token=None)
110
+
111
+ async def get_transcript_activities(
112
+ self,
113
+ channel_id: str,
114
+ conversation_id: str,
115
+ continuation_token: Optional[str] = None,
116
+ start_date: Optional[datetime] = None,
117
+ page_bytes: int = 512 * 1024,
118
+ ) -> PagedResult[Activity]:
119
+ """
120
+ Read activities from the transcript file (paged by byte size).
121
+ :param channel_id: The channel ID of the conversation.
122
+ :param conversation_id: The conversation ID to read activities from.
123
+ :param continuation_token: Optional continuation token (byte offset as string).
124
+ :param start_date: Optional filter to only include activities on or after this date.
125
+ :param page_bytes: Maximum number of bytes to read (default: 512kB).
126
+ :return: A PagedResult containing a list of Activities and an optional continuation token.
127
+ """
128
+ file_path = self._file_path(channel_id, conversation_id)
129
+
130
+ def _read_page() -> Tuple[List[Activity], Optional[str]]:
131
+ if not file_path.exists():
132
+ return [], None
133
+
134
+ offset = int(continuation_token) if continuation_token else 0
135
+ results: List[Activity] = []
136
+
137
+ with open(file_path, "rb") as f:
138
+ f.seek(0, os.SEEK_END)
139
+ end = f.tell()
140
+ if offset > end:
141
+ return [], None
142
+ f.seek(offset)
143
+ # Read a chunk
144
+ raw = f.read(page_bytes)
145
+ # Extend to end of current line to avoid cutting a JSON record in half
146
+ # (read until newline or EOF)
147
+ while True:
148
+ ch = f.read(1)
149
+ if not ch:
150
+ break
151
+ raw += ch
152
+ if ch == b"\n":
153
+ break
154
+
155
+ next_offset = f.tell()
156
+ # Decode and split lines
157
+ text = raw.decode("utf-8", errors="ignore")
158
+ lines = [ln for ln in text.splitlines() if ln.strip()]
159
+
160
+ # Parse JSONL
161
+ for ln in lines:
162
+ try:
163
+ a = Activity.model_validate_json(ln)
164
+ except Exception:
165
+ # Skip malformed lines
166
+ continue
167
+ if start_date:
168
+ if a.timestamp and a.timestamp < start_date.astimezone(
169
+ timezone.utc
170
+ ):
171
+ continue
172
+ results.append(a)
173
+
174
+ token = str(next_offset) if next_offset < end else None
175
+ return results, token
176
+
177
+ items, token = await asyncio.to_thread(_read_page)
178
+ return PagedResult(items=items, continuation_token=token)
179
+
180
+ async def delete_transcript(self, channel_id: str, conversation_id: str) -> None:
181
+ """Delete the specified conversation transcript file (no-op if absent)."""
182
+ file_path = self._file_path(channel_id, conversation_id)
183
+
184
+ def _delete() -> None:
185
+ try:
186
+ file_path.unlink(missing_ok=True)
187
+ except Exception:
188
+ # Best-effort deletion: ignore failures (locked file, etc.)
189
+ pass
190
+
191
+ await asyncio.to_thread(_delete)
192
+
193
+ # ----------------------------
194
+ # Helpers
195
+ # ----------------------------
196
+
197
+ def _channel_dir(self, channel_id: str) -> Path:
198
+ return self._root / _sanitize(self._safe, channel_id)
199
+
200
+ def _file_path(self, channel_id: str, conversation_id: str) -> Path:
201
+ safe_channel = _sanitize(self._safe, channel_id)
202
+ safe_conv = _sanitize(self._safe, conversation_id)
203
+ return self._root / safe_channel / f"{safe_conv}.transcript"
204
+
205
+
206
+ # ----------------------------
207
+ # Module-level helpers
208
+ # ----------------------------
209
+
210
+
211
+ def _sanitize(pattern: re.Pattern[str], value: str) -> str:
212
+ # Replace path-separators and illegal filename chars with '-'
213
+ value = (value or "").strip().replace(os.sep, "-").replace("/", "-")
214
+ value = pattern.sub("-", value)
215
+ return value or "unknown"
216
+
217
+
218
+ def _get_ids(activity: Activity) -> Tuple[str, str]:
219
+ # Works with both dict-like and object-like Activity
220
+ def _get(obj: Any, *path: str) -> Optional[Any]:
221
+ cur = obj
222
+ for key in path:
223
+ if cur is None:
224
+ return None
225
+ if isinstance(cur, dict):
226
+ cur = cur.get(key)
227
+ else:
228
+ cur = getattr(cur, key, None)
229
+ return cur
230
+
231
+ channel_id = _get(activity, "channel_id") or _get(activity, "channelId")
232
+ conversation_id = _get(activity, "conversation", "id")
233
+ if not channel_id or not conversation_id:
234
+ raise ValueError("Activity must include channel_id and conversation.id")
235
+ return str(channel_id), str(conversation_id)
236
+
237
+
238
+ def _to_plain_dict(activity: Activity) -> Dict[str, Any]:
239
+
240
+ if isinstance(activity, dict):
241
+ return activity
242
+ # Best-effort conversion for dataclass/attr/objects
243
+ try:
244
+ import dataclasses
245
+
246
+ if dataclasses.is_dataclass(activity):
247
+ return dataclasses.asdict(activity) # type: ignore[arg-type]
248
+ except Exception:
249
+ pass
250
+ try:
251
+ return json.loads(
252
+ json.dumps(activity, default=lambda o: getattr(o, "__dict__", str(o)))
253
+ )
254
+ except Exception:
255
+ # Fallback: minimal projection
256
+ channel_id, conversation_id = _get_ids(activity)
257
+ return {
258
+ "type": getattr(activity, "type", "message"),
259
+ "id": getattr(activity, "id", None),
260
+ "channel_id": channel_id,
261
+ "conversation": {"id": conversation_id},
262
+ "text": getattr(activity, "text", None),
263
+ }
264
+
265
+
266
+ def _utc_iso_now() -> str:
267
+ return datetime.now(timezone.utc).isoformat()
@@ -5,16 +5,28 @@ import random
5
5
  import string
6
6
  import json
7
7
 
8
+ from typing import Any, Optional
8
9
  from abc import ABC, abstractmethod
9
10
  from datetime import datetime, timezone
10
11
  from queue import Queue
11
12
  from typing import Awaitable, Callable, List, Optional
13
+ from dataclasses import dataclass
12
14
 
13
15
  from microsoft_agents.activity import Activity, ChannelAccount
14
16
  from microsoft_agents.activity.activity import ConversationReference
15
17
  from microsoft_agents.activity.activity_types import ActivityTypes
16
18
  from microsoft_agents.activity.conversation_reference import ActivityEventNames
17
19
  from microsoft_agents.hosting.core.middleware_set import Middleware, TurnContext
20
+ from typing import Generic, TypeVar
21
+
22
+
23
+ T = TypeVar("T")
24
+
25
+
26
+ @dataclass
27
+ class PagedResult(Generic[T]):
28
+ items: List[T]
29
+ continuation_token: Optional[str] = None
18
30
 
19
31
 
20
32
  class TranscriptLogger(ABC):
@@ -4,7 +4,7 @@
4
4
  from threading import Lock
5
5
  from datetime import datetime, timezone
6
6
  from typing import List
7
- from .transcript_logger import TranscriptLogger
7
+ from .transcript_logger import TranscriptLogger, PagedResult
8
8
  from .transcript_info import TranscriptInfo
9
9
  from microsoft_agents.activity import Activity
10
10
 
@@ -52,7 +52,7 @@ class TranscriptMemoryStore(TranscriptLogger):
52
52
  conversation_id: str,
53
53
  continuation_token: str = None,
54
54
  start_date: datetime = datetime.min.replace(tzinfo=timezone.utc),
55
- ) -> tuple[list[Activity], str]:
55
+ ) -> PagedResult[Activity]:
56
56
  """
57
57
  Retrieves activities for a given channel and conversation, optionally filtered by start_date.
58
58
 
@@ -60,7 +60,7 @@ class TranscriptMemoryStore(TranscriptLogger):
60
60
  :param conversation_id: The conversation ID to filter activities.
61
61
  :param continuation_token: (Unused) Token for pagination.
62
62
  :param start_date: Only activities with timestamp >= start_date are returned. None timestamps are treated as datetime.min.
63
- :return: A tuple containing the filtered list of Activity objects and a continuation token (always None).
63
+ :return: A PagedResult containing the filtered list of Activity objects and a continuation token (always None).
64
64
  :raises ValueError: If channel_id or conversation_id is None.
65
65
  """
66
66
  if not channel_id:
@@ -98,7 +98,9 @@ class TranscriptMemoryStore(TranscriptLogger):
98
98
  >= start_date
99
99
  ]
100
100
 
101
- return filtered_sorted_activities, None
101
+ return PagedResult(
102
+ items=filtered_sorted_activities, continuation_token=None
103
+ )
102
104
 
103
105
  async def delete_transcript(self, channel_id: str, conversation_id: str) -> None:
104
106
  """
@@ -126,13 +128,13 @@ class TranscriptMemoryStore(TranscriptLogger):
126
128
 
127
129
  async def list_transcripts(
128
130
  self, channel_id: str, continuation_token: str = None
129
- ) -> tuple[list[TranscriptInfo], str]:
131
+ ) -> PagedResult[TranscriptInfo]:
130
132
  """
131
133
  Lists all transcripts (unique conversation IDs) for a given channel.
132
134
 
133
135
  :param channel_id: The channel ID to list transcripts for.
134
136
  :param continuation_token: (Unused) Token for pagination.
135
- :return: A tuple containing a list of TranscriptInfo objects and a continuation token (always None).
137
+ :return: A PagedResult containing a list of TranscriptInfo objects and a continuation token (always None).
136
138
  :raises ValueError: If channel_id is None.
137
139
  """
138
140
  if not channel_id:
@@ -151,4 +153,4 @@ class TranscriptMemoryStore(TranscriptLogger):
151
153
  TranscriptInfo(channel_id=channel_id, conversation_id=conversation_id)
152
154
  for conversation_id in conversations
153
155
  ]
154
- return transcript_infos, None
156
+ return PagedResult(items=transcript_infos, continuation_token=None)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: microsoft-agents-hosting-core
3
- Version: 0.4.0.dev14
3
+ Version: 0.4.0.dev16
4
4
  Summary: Core library for Microsoft Agents
5
5
  Author: Microsoft Corporation
6
6
  Project-URL: Homepage, https://github.com/microsoft/Agents
@@ -8,7 +8,7 @@ Classifier: Programming Language :: Python :: 3
8
8
  Classifier: License :: OSI Approved :: MIT License
9
9
  Classifier: Operating System :: OS Independent
10
10
  Requires-Python: >=3.9
11
- Requires-Dist: microsoft-agents-activity==0.4.0.dev14
11
+ Requires-Dist: microsoft-agents-activity==0.4.0.dev16
12
12
  Requires-Dist: pyjwt>=2.10.1
13
13
  Requires-Dist: isodate>=0.6.1
14
14
  Requires-Dist: azure-core>=1.30.0
@@ -4,11 +4,11 @@ microsoft_agents/hosting/core/agent.py,sha256=K8v84y8ULP7rbcMKg8LxaM3haAq7f1oHFC
4
4
  microsoft_agents/hosting/core/card_factory.py,sha256=UDmPEpOk2SpEr9ShN9Q0CiaI_GTD3qjHgkDMOWinW9I,6926
5
5
  microsoft_agents/hosting/core/channel_adapter.py,sha256=zEfQILXagYujO_dTsqvTbqdegeOW4qEHP8SdJb_IPEY,10088
6
6
  microsoft_agents/hosting/core/channel_api_handler_protocol.py,sha256=Nl4aOqpADWLAeIueI166TEmPwZX4XYnOiVGH9lzBuaY,4411
7
- microsoft_agents/hosting/core/channel_service_adapter.py,sha256=_OOgiatjpK70K7WMsx7x-VjEuizZgMyVYKMtTzfM2zs,17483
8
- microsoft_agents/hosting/core/channel_service_client_factory_base.py,sha256=n4K1jBEca2lW1bF0RS5MlA4FcA469HxqdhcyZNSiN_U,1465
7
+ microsoft_agents/hosting/core/channel_service_adapter.py,sha256=UdJKZIcp0xR8oMDpQWhbdc6MtqBsU-gVcN7ksHZVbHY,17412
8
+ microsoft_agents/hosting/core/channel_service_client_factory_base.py,sha256=UKYz8gCJmmwTRwCBle8O_dSGhflVsRRf2fPFZgYgzWo,1609
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
11
- microsoft_agents/hosting/core/rest_channel_service_client_factory.py,sha256=JIl9wRQI2OEgdMA43_s4Y8RtdJwsJ2Q2uIH1-iZ745I,5025
11
+ microsoft_agents/hosting/core/rest_channel_service_client_factory.py,sha256=TBdgg_5nsmH1Ouhpsb7bV4OzdS-jKQxb9t9j45PevP4,6122
12
12
  microsoft_agents/hosting/core/turn_context.py,sha256=df7TB1uXurgoAk338OF6taVfVgS58v662A9D9-GLP64,14794
13
13
  microsoft_agents/hosting/core/_oauth/__init__.py,sha256=7KvwQEiINtA6KFlpgOLWF0acxtkS4hu-qlqHlQWH7AI,310
14
14
  microsoft_agents/hosting/core/_oauth/_flow_state.py,sha256=WgvwVbEVsVb5ax75BleCVfGDs-Zw3beDZGlzW6jraCg,2185
@@ -68,7 +68,7 @@ microsoft_agents/hosting/core/connector/get_product_info.py,sha256=SDxPqBCzzQLEU
68
68
  microsoft_agents/hosting/core/connector/user_token_base.py,sha256=kEJxGMMl-RYzqDKqjwjQPvc-acih0cJo3hOEjmmom5I,1195
69
69
  microsoft_agents/hosting/core/connector/user_token_client_base.py,sha256=JTkbG70YXEnAQA_2yJn9dUGQ-hSCwNgU0C-V8y7kGEI,376
70
70
  microsoft_agents/hosting/core/connector/client/__init__.py,sha256=6JdKhmm7btmo0omxMBd8PJbtGFk0cnMwVUoStyW7Ft0,143
71
- microsoft_agents/hosting/core/connector/client/connector_client.py,sha256=o1sT5-mQVEgJta3HGAVmEE5GLOteCkzfjEZaCmEG6l0,22337
71
+ microsoft_agents/hosting/core/connector/client/connector_client.py,sha256=kN9SFbZTReXHjfJji1mJsFmlmvSyaq3hI-bV09W32r8,23473
72
72
  microsoft_agents/hosting/core/connector/client/user_token_client.py,sha256=a6Y4pD8Ae-pEFA8FrXgb_mb0TzWjo81TMeDaRPqcGIw,10896
73
73
  microsoft_agents/hosting/core/connector/teams/__init__.py,sha256=3ZMPGYyZ15EwvfQzfJJQy1J58oIt4InSxibl3BN6R54,100
74
74
  microsoft_agents/hosting/core/connector/teams/teams_connector_client.py,sha256=XGQDTYHrA_I9n9JlxGST5eesjsFhz2dnSaMSuyoFnKU,12676
@@ -76,17 +76,18 @@ microsoft_agents/hosting/core/state/__init__.py,sha256=yckKi1wg_86ng-DL9Q3R49QiW
76
76
  microsoft_agents/hosting/core/state/agent_state.py,sha256=p6AoKSPNpPR6Ubw_APjrQ_KyaQ9AqRYS63n4HcmMnzs,13211
77
77
  microsoft_agents/hosting/core/state/state_property_accessor.py,sha256=kpiNnzkZ6el-oRITRbRkk1Faa_CPFxpJQdvSGxIJP70,1392
78
78
  microsoft_agents/hosting/core/state/user_state.py,sha256=zEigX-sroNAyoQAxQjG1OgmJQKjk1zOkdeqylFg7M2E,1484
79
- microsoft_agents/hosting/core/storage/__init__.py,sha256=X2jWX0qc2YdFNhwOct5Q7VJ9Q5VnOpKB0RqC7HG2_Qs,611
79
+ microsoft_agents/hosting/core/storage/__init__.py,sha256=Df_clI0uMRgcr4Td-xkP83bU_mGae7_gRMhtVDPZDmE,729
80
80
  microsoft_agents/hosting/core/storage/_type_aliases.py,sha256=VCKtjiCBrhEsGSm3zVVSSccdoiY02GYhABvrLjhAcz8,72
81
81
  microsoft_agents/hosting/core/storage/error_handling.py,sha256=zH34d7s4pJG_uajpBWhrtTpH2eMy88kSKaqvOqtbgzY,1265
82
82
  microsoft_agents/hosting/core/storage/memory_storage.py,sha256=NADem1wQE1MOG1qMriYw4NjILHEBDbIG5HT6wvHfG2M,2353
83
83
  microsoft_agents/hosting/core/storage/storage.py,sha256=vft_Kw4pkzo8NnBEyDx7gAn1Ndg2I9ePaxnuxbKVHzs,3227
84
84
  microsoft_agents/hosting/core/storage/store_item.py,sha256=4LSkuI0H0lgWig88YoHFn6BP8Bx44YbyuvqBvaBNdEM,276
85
+ microsoft_agents/hosting/core/storage/transcript_file_store.py,sha256=7-ngOW5AuVPEHiAZJeZe_a5Qn4lQxlAiexjAwPiOJJU,10112
85
86
  microsoft_agents/hosting/core/storage/transcript_info.py,sha256=5VN32j99tshChAffvuZ6D3GH3ABCZsQGHC_bYDAwFOk,328
86
- microsoft_agents/hosting/core/storage/transcript_logger.py,sha256=PxAZTZVFRC2q_i1JABo-JFOPEv9QyXCMe5wZdiiq6_g,8007
87
- microsoft_agents/hosting/core/storage/transcript_memory_store.py,sha256=xdX4CtyDczN5jCoi-ZtXzBZq52ahsk3IQ_Y6qkmub8U,6323
87
+ microsoft_agents/hosting/core/storage/transcript_logger.py,sha256=_atDk3CJ05fIVMhlWGNa91IiM9bGLmOhasFko8Lxjhk,8237
88
+ microsoft_agents/hosting/core/storage/transcript_memory_store.py,sha256=v1Ud9LSs8m5c9_Fa8i49SuAjw80dX1hDciqbRduDEOE,6444
88
89
  microsoft_agents/hosting/core/storage/transcript_store.py,sha256=ka74o0WvI5GhMZcFqSxVdamBhGzZcDZe6VNkG-sMy74,1944
89
- microsoft_agents_hosting_core-0.4.0.dev14.dist-info/METADATA,sha256=9YJpYQ6DRGc6APQll2KtKeM4t0pccibqAbKltcwtEXA,586
90
- microsoft_agents_hosting_core-0.4.0.dev14.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
91
- microsoft_agents_hosting_core-0.4.0.dev14.dist-info/top_level.txt,sha256=lWKcT4v6fTA_NgsuHdNvuMjSrkiBMXohn64ApY7Xi8A,17
92
- microsoft_agents_hosting_core-0.4.0.dev14.dist-info/RECORD,,
90
+ microsoft_agents_hosting_core-0.4.0.dev16.dist-info/METADATA,sha256=xGmh4XPcm_fgm2ehlFAy0l959WNGh8575S5zhiHADsI,586
91
+ microsoft_agents_hosting_core-0.4.0.dev16.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
92
+ microsoft_agents_hosting_core-0.4.0.dev16.dist-info/top_level.txt,sha256=lWKcT4v6fTA_NgsuHdNvuMjSrkiBMXohn64ApY7Xi8A,17
93
+ microsoft_agents_hosting_core-0.4.0.dev16.dist-info/RECORD,,