Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new proactive messaging subsystem to microsoft-agents-hosting-core, enabling conversation reference persistence and out-of-band/proactive turns (continue existing conversations, send one-off activities, and create new conversations), and includes a runnable sample demonstrating the flows.
Changes:
- Introduces
core.app.proactivepackage (Proactive,Conversation, builders, options) and wires it intoAgentApplicationviaApplicationOptions.proactive. - Exposes proactive types via package
__init__.pyexports for public consumption. - Adds a
test_samples/proactivesample app + docs + environment template.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| test_samples/proactive/requirements.txt | Sample dependencies for running the proactive agent. |
| test_samples/proactive/README.md | End-to-end instructions and usage patterns for proactive flows. |
| test_samples/proactive/proactive_agent.py | Runnable aiohttp sample demonstrating store/continue/notify and OAuth-guarded continuations. |
| test_samples/proactive/env.TEMPLATE | Environment template for the proactive sample (AAD + OAuth connection). |
| libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py | Core proactive manager: persist conversations, continue/send proactively, create new conversations. |
| libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive_options.py | Configuration options for proactive subsystem. |
| libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/create_conversation_options.py | Options model + validation for proactive conversation creation. |
| libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation.py | Persisted conversation reference + filtered claims serialization/validation. |
| libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_reference_builder.py | Fluent builder for ConversationReference. |
| libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_builder.py | Fluent builder for Conversation objects. |
| libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/init.py | Public exports for proactive package. |
| libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py | Adds ApplicationOptions.proactive configuration hook. |
| libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.py | Initializes and exposes AgentApplication.proactive. |
| libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/init.py | Re-exports proactive types from the main app package. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| "Commands:\n" | ||
| " **-s** — store this conversation for later\n" | ||
| " **-c** — proactively continue this conversation (requires sign-in)\n" | ||
| " **-c \<id\>** — proactively continue a stored conversation\n" |
There was a problem hiding this comment.
These help strings include \<id\> inside normal Python string literals, which triggers an "invalid escape sequence" warning. Prefer using <id> directly (Markdown renders fine) or escape the backslashes (\\<id\\>), or make the string a raw literal.
| " **-c \<id\>** — proactively continue a stored conversation\n" | |
| " **-c <id>** — proactively continue a stored conversation\n" |
| await context.send_activity( | ||
| "Commands:\n" | ||
| " **-s** — store this conversation\n" | ||
| " **-c** — proactively continue this conversation (requires sign-in)\n" | ||
| " **-c \<id\>** — proactively continue a stored conversation\n" | ||
| " **-signin** — sign in with the 'me' OAuth connection\n" | ||
| " **-signout** — sign out from the 'me' OAuth connection\n" |
There was a problem hiding this comment.
Same invalid escape sequence issue here (\<id\> inside a normal string literal). This can raise SyntaxWarning/DeprecationWarning under newer Python versions or CI configs treating warnings as errors.
| def validate(self) -> None: | ||
| """ | ||
| Raise :exc:`ValueError` if required fields are missing. | ||
|
|
||
| :raises ValueError: If ``identity``, ``channel_id``, or ``parameters`` | ||
| are absent or ``service_url`` is missing and cannot be derived. | ||
| """ | ||
| if not self.identity: | ||
| raise ValueError("CreateConversationOptions.identity is required.") | ||
| if not self.channel_id: | ||
| raise ValueError("CreateConversationOptions.channel_id is required.") | ||
| if not self.parameters: | ||
| raise ValueError("CreateConversationOptions.parameters is required.") |
There was a problem hiding this comment.
validate()'s docstring says it errors when service_url is missing and cannot be derived, but the implementation never validates service_url. Since Proactive.create_conversation forwards service_url to the adapter (which requires it), this should either validate that service_url is set or implement a derivation strategy from channel_id.
| :raises ValueError: If required fields in *options* are missing. | ||
| """ | ||
| options.validate() | ||
|
|
||
| new_conversation: Optional[Conversation] = None | ||
| captured_exc: Optional[BaseException] = None | ||
|
|
||
| audience = options.audience or options.identity.get_token_audience() | ||
|
|
||
| async def _callback(context: "TurnContext") -> None: | ||
| nonlocal new_conversation, captured_exc | ||
| try: | ||
| reference = context.activity.get_conversation_reference() | ||
| new_conversation = Conversation( | ||
| claims=options.identity, | ||
| conversation_reference=reference, | ||
| ) | ||
|
|
||
| if options.store_conversation: | ||
| await self.store_conversation(new_conversation) | ||
|
|
||
| if handler is not None: | ||
| state = await self._load_state(context) | ||
| await handler(context, state) | ||
| await state.save(context) | ||
| except Exception as exc: # noqa: BLE001 | ||
| captured_exc = exc | ||
|
|
||
| await adapter.create_conversation( | ||
| options.identity.get_app_id() or "", | ||
| options.channel_id, | ||
| options.service_url or "", | ||
| audience, | ||
| options.parameters, | ||
| _callback, | ||
| ) | ||
|
|
||
| if captured_exc is not None: | ||
| raise captured_exc | ||
|
|
There was a problem hiding this comment.
create_conversation() passes options.service_url or "" into adapter.create_conversation(), but ChannelServiceAdapter.create_conversation() raises if service_url is falsy. Either require service_url in CreateConversationOptions.validate() or derive a default here based on options.channel_id. Also, this method can return None (if the callback never sets new_conversation) despite the return type being Conversation; add an explicit post-condition check and raise a clear error if the adapter didn't produce a conversation reference.
| ``conversation``, or ``service_url`` are absent. | ||
| """ | ||
| if not self.conversation_reference: | ||
| raise ValueError("Conversation.conversation_reference is required.") | ||
| if not self.conversation_reference.conversation: | ||
| raise ValueError( | ||
| "Conversation.conversation_reference.conversation is required." |
There was a problem hiding this comment.
Conversation.validate() doesn't verify that conversation_reference.conversation.id is non-empty, but Proactive.store_conversation() uses that ID as the storage key. An empty ID would cause collisions/overwrites and make proactive continuation impossible; consider validating that conversation.id is present and non-empty.
| if options.proactive: | ||
| proactive_opts = options.proactive | ||
| if not proactive_opts.storage: | ||
| proactive_opts.storage = self._options.storage | ||
| self._proactive = Proactive(self, proactive_opts) | ||
|
|
There was a problem hiding this comment.
This mutates the caller-provided options.proactive instance by setting storage on it. That side effect can be surprising if the same ProactiveOptions object is reused elsewhere; consider constructing a new ProactiveOptions (or leaving resolution to Proactive._storage) instead of mutating the input options.
|
|
||
| from __future__ import annotations | ||
|
|
||
| from dataclasses import dataclass, field |
There was a problem hiding this comment.
field is imported but unused in this module. Please remove the unused import to avoid lint/type-check noise.
| from dataclasses import dataclass, field | |
| from dataclasses import dataclass |
|
|
||
|
|
||
| class Proactive(Generic[StateT]): | ||
| """ | ||
| Proactive messaging support for :class:`~microsoft_agents.hosting.core.app.agent_application.AgentApplication`. | ||
|
|
||
| This class is attached to :attr:`AgentApplication.proactive` automatically when | ||
| :attr:`~microsoft_agents.hosting.core.app.app_options.ApplicationOptions.proactive` options are | ||
| provided. It provides methods to: | ||
|
|
||
| * **Persist** a conversation reference so it can be resumed later | ||
| (:meth:`store_conversation`, :meth:`get_conversation`, | ||
| :meth:`delete_conversation`). | ||
| * **Continue** an existing conversation proactively | ||
| (:meth:`continue_conversation`). | ||
| * **Send** a single activity into an existing conversation | ||
| (:meth:`send_activity`). | ||
| * **Create** a brand-new conversation with a user | ||
| (:meth:`create_conversation`). | ||
|
|
||
| Example — store then resume:: | ||
|
|
||
| # During a normal turn, save the conversation for later: | ||
| await app.proactive.store_conversation(context) | ||
|
|
||
| # Later (e.g. from a webhook), resume it: | ||
| async def notify(context, state): | ||
| await context.send_activity("Here is your notification!") | ||
|
|
There was a problem hiding this comment.
This new proactive subsystem introduces several new behaviors (conversation persistence, proactive continuation, OAuth token gating, create_conversation) but no corresponding tests were added. Given existing pytest coverage under tests/hosting_core/, please add unit tests for store/get/delete, continue_conversation/send_activity error propagation, unsigned-in token_handlers behavior, and create_conversation service_url validation/derivation.
…into users/axsuarez/proactive-improvements
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated 7 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| await adapter.create_conversation( | ||
| options.identity.get_app_id() or "", | ||
| options.channel_id, | ||
| options.service_url or "", | ||
| audience, | ||
| options.parameters, | ||
| _callback, | ||
| ) |
There was a problem hiding this comment.
Proactive.create_conversation() passes options.service_url or "" into adapter.create_conversation(...), but CloudAdapter.create_conversation() explicitly requires a non-empty service_url and raises TypeError otherwise. Either make CreateConversationOptions.service_url required (and validate it), or add a reliable derivation path here (and validate that derivation succeeded) before calling the adapter.
| if not self._channel_id: | ||
| raise ValueError("ConversationReferenceBuilder: channel_id is required.") | ||
|
|
||
| service_url = self._service_url or _service_url_for_channel(self._channel_id) | ||
|
|
||
| agent = ( | ||
| ChannelAccount(id=self._agent_id, name=self._agent_name) | ||
| if self._agent_id | ||
| else None | ||
| ) | ||
| user = ( | ||
| ChannelAccount(id=self._user_id, name=self._user_name) | ||
| if self._user_id | ||
| else None | ||
| ) | ||
|
|
||
| return ConversationReference( | ||
| channel_id=self._channel_id, | ||
| conversation=ConversationAccount(id=self._conversation_id or ""), | ||
| service_url=service_url, |
There was a problem hiding this comment.
ConversationReferenceBuilder.build() can construct ConversationAccount(id=self._conversation_id or ""), but ConversationAccount.id is a NonEmptyString and will raise a Pydantic validation error if no conversation ID was set (e.g., when using create_for_agent(...)). Add an explicit check for _conversation_id and raise ValueError with a clear message before building the model.
| def build(self) -> Conversation: | ||
| """ | ||
| Construct the :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation`. | ||
|
|
||
| :raises ValueError: If required fields (``channel_id``) are missing. | ||
| :return: The built :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation`. | ||
| :rtype: :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation` | ||
| """ | ||
| if not self._channel_id: | ||
| raise ValueError("ConversationBuilder: channel_id is required.") | ||
|
|
||
| agent = ( | ||
| ChannelAccount(id=self._agent_id, name=self._agent_name) | ||
| if self._agent_id | ||
| else None | ||
| ) | ||
| user = ( | ||
| ChannelAccount(id=self._user_id, name=self._user_name) | ||
| if self._user_id | ||
| else None | ||
| ) | ||
|
|
||
| reference = ConversationReference( | ||
| channel_id=self._channel_id, | ||
| service_url=self._service_url or _service_url_for_channel(self._channel_id), | ||
| conversation=ConversationAccount( | ||
| id=self._conversation_id or "", | ||
| name=self._conversation_name, | ||
| tenant_id=self._tenant_id, | ||
| ), |
There was a problem hiding this comment.
ConversationBuilder.build() uses ConversationAccount(id=self._conversation_id or ""), but ConversationAccount.id is a required NonEmptyString and will raise a Pydantic validation error if with_conversation(...) was never called. Add an explicit _conversation_id check (and raise ValueError) so callers get a predictable, descriptive error.
| @@ -0,0 +1,170 @@ | |||
| # Proactive Agent Sample | |||
|
|
|||
| Demonstrates how`AgentApplication.proactive` enables proactive messaging entirely through the | |||
There was a problem hiding this comment.
Minor formatting issue: the sentence reads Demonstrates howAgentApplication.proactive ... (missing a space before the inline code span). Add a space so Markdown renders correctly.
| Demonstrates how`AgentApplication.proactive` enables proactive messaging entirely through the | |
| Demonstrates how `AgentApplication.proactive` enables proactive messaging entirely through the |
| _options: ApplicationOptions | ||
| _adapter: Optional[ChannelServiceAdapter] = None | ||
| _auth: Optional[Authorization] = None | ||
| _proactive: Optional[Proactive] = None |
There was a problem hiding this comment.
_proactive and the proactive property drop the generic parameterization of Proactive (defined as Proactive[StateT]). Consider annotating these as Optional[Proactive[StateT]] / Proactive[StateT] to preserve type-safety for users relying on typing.
| _proactive: Optional[Proactive] = None | |
| _proactive: Optional[Proactive[StateT]] = None |
| async def store_conversation( | ||
| self, | ||
| context_or_conversation: "TurnContext | Conversation", | ||
| ) -> None: | ||
| """ | ||
| Persist a :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation` | ||
| to storage so it can be resumed later. | ||
|
|
||
| Accepts either: | ||
|
|
||
| * A :class:`~microsoft_agents.hosting.core.turn_context.TurnContext` — the | ||
| :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation` | ||
| is built automatically from the current turn. | ||
| * A :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation` | ||
| — stored directly. | ||
|
|
||
| :param context_or_conversation: The turn context or an already-built | ||
| :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation`. | ||
| :raises ValueError: If required fields on the conversation are missing. | ||
| """ | ||
| from microsoft_agents.hosting.core.turn_context import TurnContext | ||
|
|
||
| if isinstance(context_or_conversation, TurnContext): | ||
| conversation = Conversation.from_turn_context(context_or_conversation) | ||
| else: | ||
| conversation = context_or_conversation | ||
|
|
||
| conversation.validate() | ||
| key = self._storage_key(conversation.conversation_reference.conversation.id) | ||
| logger.debug("Storing conversation with key: %s", key) | ||
| await self._storage.write({key: conversation}) | ||
|
|
||
| async def get_conversation(self, conversation_id: str) -> Optional[Conversation]: | ||
| """ | ||
| Retrieve a previously stored | ||
| :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation`. | ||
|
|
||
| :param conversation_id: The conversation ID used as the storage key. | ||
| :type conversation_id: str | ||
| :return: The stored :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation`, | ||
| or ``None`` if not found. | ||
| :rtype: Optional[:class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation`] | ||
| """ | ||
| key = self._storage_key(conversation_id) | ||
| results = await self._storage.read([key], target_cls=Conversation) | ||
| return results.get(key) | ||
|
|
||
| async def delete_conversation(self, conversation_id: str) -> None: | ||
| """ | ||
| Delete a previously stored conversation. | ||
|
|
||
| :param conversation_id: The conversation ID to delete. | ||
| :type conversation_id: str | ||
| """ | ||
| key = self._storage_key(conversation_id) | ||
| logger.debug("Deleting conversation with key: %s", key) | ||
| await self._storage.delete([key]) | ||
|
|
There was a problem hiding this comment.
The new proactive messaging subsystem introduces substantial new behavior (conversation persistence, continue/send flows, OAuth token guard, and create-conversation), but this PR doesn’t add tests alongside the existing tests/hosting_core/app/* suite. Please add unit tests covering at least: store/get/delete round-trips, continue_conversation() state load/save + token_handlers behavior, and create_conversation() validation/error paths.
| audience = options.audience or options.identity.get_token_audience() | ||
|
|
||
| async def _callback(context: "TurnContext") -> None: | ||
| nonlocal new_conversation, captured_exc | ||
| try: | ||
| reference = context.activity.get_conversation_reference() | ||
| new_conversation = Conversation( | ||
| claims=options.identity, | ||
| conversation_reference=reference, | ||
| ) | ||
|
|
||
| if options.store_conversation: | ||
| await self.store_conversation(new_conversation) | ||
|
|
||
| if handler is not None: | ||
| state = await self._load_state(context) | ||
| await handler(context, state) | ||
| await state.save(context) | ||
| except Exception as exc: # noqa: BLE001 | ||
| captured_exc = exc | ||
|
|
||
| await adapter.create_conversation( | ||
| options.identity.get_app_id() or "", | ||
| options.channel_id, | ||
| options.service_url or "", | ||
| audience, | ||
| options.parameters, | ||
| _callback, | ||
| ) |
There was a problem hiding this comment.
Proactive.create_conversation() uses options.identity.get_app_id() or "" for agent_app_id. If the identity lacks both aud and appid, this silently becomes an empty string, which will lead to outbound auth failures later. Consider validating that options.identity.get_app_id() resolves to a non-empty value before calling adapter.create_conversation(...) (and fail fast with a clear error).
This pull request introduces a new "proactive messaging" subsystem to the Microsoft Agents Hosting Core library, allowing the application to store conversation references and initiate proactive (out-of-band) conversations. The changes add new types and configuration options, integrate the subsystem into the
AgentApplication, and expose a new API for managing proactive messaging.The most important changes are:
Proactive Messaging Subsystem Integration:
proactivepackage with types likeProactive,ProactiveOptions,Conversation,ConversationBuilder, and related helpers, enabling the storage and creation of conversation references for proactive messaging. ([[1]](https://github.com/microsoft/Agents-for-python/pull/350/files#diff-ab0b620edce9ae748d8a4beaa059710084153346388f3baeb0c87fd77b61868eR1-R20),[[2]](https://github.com/microsoft/Agents-for-python/pull/350/files#diff-3b51491b418dffd0f7cd4a6ceef3da178085d86dc0b3aaa36101837b35f41ba0R1-R142),[[3]](https://github.com/microsoft/Agents-for-python/pull/350/files#diff-b0081ee5fb63a260f726e092191d0efd4d6d750683fcac49c23a00b64a9cc196R1-R235))AgentApplicationclass to optionally initialize and expose aproactivemessaging manager whenProactiveOptionsare provided in the application options, including error handling if accessed when not configured. ([[1]](https://github.com/microsoft/Agents-for-python/pull/350/files#diff-a0c0028a01efc798e89b84f1bca0d4323b71753bd6f43d02800f29e66bc766a6R71),[[2]](https://github.com/microsoft/Agents-for-python/pull/350/files#diff-a0c0028a01efc798e89b84f1bca0d4323b71753bd6f43d02800f29e66bc766a6R150-R155),[[3]](https://github.com/microsoft/Agents-for-python/pull/350/files#diff-a0c0028a01efc798e89b84f1bca0d4323b71753bd6f43d02800f29e66bc766a6R225-R244))Configuration and Public API:
ApplicationOptionsto include an optionalproactivefield for configuring proactive messaging. ([libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.pyR93-R99](https://github.com/microsoft/Agents-for-python/pull/350/files#diff-52c9390d176de19f2e6ecb00591186c7c3b271d30ff66eb41f3b72093a181e89R93-R99))__init__.pyfiles and the__all__lists, making them available for import from the main app package. ([[1]](https://github.com/microsoft/Agents-for-python/pull/350/files#diff-65135729f0818c154cc80932e3046c5c437aa0d3c029abfdcba4b2f27ab4c122R24-R33),[[2]](https://github.com/microsoft/Agents-for-python/pull/350/files#diff-65135729f0818c154cc80932e3046c5c437aa0d3c029abfdcba4b2f27ab4c122R60-R66),[[3]](https://github.com/microsoft/Agents-for-python/pull/350/files#diff-ab0b620edce9ae748d8a4beaa059710084153346388f3baeb0c87fd77b61868eR1-R20),[[4]](https://github.com/microsoft/Agents-for-python/pull/350/files#diff-52c9390d176de19f2e6ecb00591186c7c3b271d30ff66eb41f3b72093a181e89R20))Internal Usage:
[libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/agent_application.pyR46](https://github.com/microsoft/Agents-for-python/pull/350/files#diff-a0c0028a01efc798e89b84f1bca0d4323b71753bd6f43d02800f29e66bc766a6R46))These changes lay the foundation for enabling bots to initiate conversations with users outside the context of an immediate incoming message, a common requirement for enterprise bots and notification scenarios.