Skip to content

Proactive testing WIP#350

Draft
axelsrz wants to merge 4 commits intomainfrom
users/axsuarez/proactive-improvements
Draft

Proactive testing WIP#350
axelsrz wants to merge 4 commits intomainfrom
users/axsuarez/proactive-improvements

Conversation

@axelsrz
Copy link
Copy Markdown
Member

@axelsrz axelsrz commented Apr 2, 2026

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:

  • Added a new proactive package with types like Proactive, 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))
  • Updated the AgentApplication class to optionally initialize and expose a proactive messaging manager when ProactiveOptions are 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:

  • Extended ApplicationOptions to include an optional proactive field 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))
  • Exposed all new proactive messaging types in the public API via __init__.py files 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:

  • Updated internal imports to use the new proactive messaging types where needed in the application core. ([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.

Copilot AI review requested due to automatic review settings April 2, 2026 02:04
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.proactive package (Proactive, Conversation, builders, options) and wires it into AgentApplication via ApplicationOptions.proactive.
  • Exposes proactive types via package __init__.py exports for public consumption.
  • Adds a test_samples/proactive sample 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"
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
" **-c \<id\>** — proactively continue a stored conversation\n"
" **-c <id>** — proactively continue a stored conversation\n"

Copilot uses AI. Check for mistakes.
Comment on lines +134 to +140
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"
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +47 to +59
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.")
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +299 to +338
: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

Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +115 to +121
``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."
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +150 to +155
if options.proactive:
proactive_opts = options.proactive
if not proactive_opts.storage:
proactive_opts.storage = self._options.storage
self._proactive = Proactive(self, proactive_opts)

Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

from __future__ import annotations

from dataclasses import dataclass, field
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

field is imported but unused in this module. Please remove the unused import to avoid lint/type-check noise.

Suggested change
from dataclasses import dataclass, field
from dataclasses import dataclass

Copilot uses AI. Check for mistakes.
Comment on lines +34 to +62


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!")

Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings April 3, 2026 20:08
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +327 to +334
await adapter.create_conversation(
options.identity.get_app_id() or "",
options.channel_id,
options.service_url or "",
audience,
options.parameters,
_callback,
)
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +210 to +229
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,
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +200 to +229
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,
),
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,170 @@
# Proactive Agent Sample

Demonstrates how`AgentApplication.proactive` enables proactive messaging entirely through the
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor formatting issue: the sentence reads Demonstrates howAgentApplication.proactive ... (missing a space before the inline code span). Add a space so Markdown renders correctly.

Suggested change
Demonstrates how`AgentApplication.proactive` enables proactive messaging entirely through the
Demonstrates how `AgentApplication.proactive` enables proactive messaging entirely through the

Copilot uses AI. Check for mistakes.
_options: ApplicationOptions
_adapter: Optional[ChannelServiceAdapter] = None
_auth: Optional[Authorization] = None
_proactive: Optional[Proactive] = None
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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.

Suggested change
_proactive: Optional[Proactive] = None
_proactive: Optional[Proactive[StateT]] = None

Copilot uses AI. Check for mistakes.
Comment on lines +96 to +153
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])

Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +306 to +334
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,
)
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants