From a7a02c1abd87cdb69637aa6f51b98632b9b980c4 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Thu, 9 Apr 2026 13:56:26 +0900 Subject: [PATCH 01/22] Fix test compat for entity key validation (#5179) --- python/packages/durabletask/tests/test_executors.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python/packages/durabletask/tests/test_executors.py b/python/packages/durabletask/tests/test_executors.py index 2333f08106..8cc9900037 100644 --- a/python/packages/durabletask/tests/test_executors.py +++ b/python/packages/durabletask/tests/test_executors.py @@ -50,6 +50,7 @@ def mock_orchestration_context(mock_entity_task: Mock) -> Mock: """Provide a mock orchestration context with call_entity configured.""" context = Mock() context.call_entity = Mock(return_value=mock_entity_task) + context.new_uuid = Mock(return_value="test-uuid-1234") return context From 30a2bc3dcb5edee5f82853558dfc5d00c2c8edc8 Mon Sep 17 00:00:00 2001 From: Aayush Kataria Date: Wed, 8 Apr 2026 22:01:41 -0700 Subject: [PATCH 02/22] Python: Add Cosmos DB NoSQL Checkpoint Storage for Python Workflows (#4916) * Add CosmosCheckpointStorage for Python workflow checkpointing Add native Cosmos DB NoSQL support for workflow checkpoint storage in the Python agent-framework-azure-cosmos package, achieving parity with the existing .NET CosmosCheckpointStore. New files: - _checkpoint_storage.py: CosmosCheckpointStorage implementing the CheckpointStorage protocol with 6 methods (save, load, list_checkpoints, delete, get_latest, list_checkpoint_ids) - test_cosmos_checkpoint_storage.py: Unit and integration tests - workflow_checkpointing.py: Sample demonstrating Cosmos DB-backed workflow checkpoint/resume Auth support: - Managed identity / RBAC via Azure credential objects (DefaultAzureCredential, ManagedIdentityCredential, etc.) - Key-based auth via account key string or AZURE_COSMOS_KEY env var - Pre-created CosmosClient or ContainerProxy Key design decisions: - Partition key: /workflow_name for efficient per-workflow queries - Serialization: Reuses encode/decode_checkpoint_value for full Python object fidelity (hybrid JSON + pickle approach) - Container auto-creation via create_container_if_not_exists Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Adding cosmos checkpointer * Resolving comments * Fixing builds * Adding sample for history provider and checkpoint storage * Resolving comments * fixing builds * Resolving comments --------- Co-authored-by: Aayush Kataria Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> --- python/packages/azure-cosmos/README.md | 93 ++- .../agent_framework_azure_cosmos/__init__.py | 2 + .../_checkpoint_storage.py | 432 +++++++++++++ .../tests/test_cosmos_checkpoint_storage.py | 599 ++++++++++++++++++ .../samples/02-agents/conversations/README.md | 5 +- ...story_provider_conversation_persistence.py | 165 +++++ .../cosmos_history_provider_messages.py | 157 +++++ .../cosmos_history_provider_sessions.py | 197 ++++++ python/samples/03-workflows/README.md | 2 + .../cosmos_workflow_checkpointing.py | 201 ++++++ .../cosmos_workflow_checkpointing_foundry.py | 144 +++++ 11 files changed, 1989 insertions(+), 8 deletions(-) create mode 100644 python/packages/azure-cosmos/agent_framework_azure_cosmos/_checkpoint_storage.py create mode 100644 python/packages/azure-cosmos/tests/test_cosmos_checkpoint_storage.py create mode 100644 python/samples/02-agents/conversations/cosmos_history_provider_conversation_persistence.py create mode 100644 python/samples/02-agents/conversations/cosmos_history_provider_messages.py create mode 100644 python/samples/02-agents/conversations/cosmos_history_provider_sessions.py create mode 100644 python/samples/03-workflows/checkpoint/cosmos_workflow_checkpointing.py create mode 100644 python/samples/03-workflows/checkpoint/cosmos_workflow_checkpointing_foundry.py diff --git a/python/packages/azure-cosmos/README.md b/python/packages/azure-cosmos/README.md index d2868c78b7..a03c5c6f93 100644 --- a/python/packages/azure-cosmos/README.md +++ b/python/packages/azure-cosmos/README.md @@ -14,7 +14,7 @@ The Azure Cosmos DB integration provides `CosmosHistoryProvider` for persistent ```python from azure.identity.aio import DefaultAzureCredential -from agent_framework.azure import CosmosHistoryProvider +from agent_framework_azure_cosmos import CosmosHistoryProvider provider = CosmosHistoryProvider( endpoint="https://.documents.azure.com:443/", @@ -35,13 +35,92 @@ Container naming behavior: - Container name is configured on the provider (`container_name` or `AZURE_COSMOS_CONTAINER_NAME`) - `session_id` is used as the Cosmos partition key for reads/writes -See the [conversation samples](../../samples/02-agents/conversations/) for runnable examples, including -[`cosmos_history_provider.py`](../../samples/02-agents/conversations/cosmos_history_provider.py). +See `samples/02-agents/conversations/cosmos_history_provider.py` for a runnable example. -## Import Paths +## Cosmos DB Workflow Checkpoint Storage + +`CosmosCheckpointStorage` implements the `CheckpointStorage` protocol, enabling +durable workflow checkpointing backed by Azure Cosmos DB NoSQL. Workflows can be +paused and resumed across process restarts by persisting checkpoint state in Cosmos DB. + +### Basic Usage + +#### Managed Identity / RBAC (recommended for production) ```python -from agent_framework.azure import CosmosHistoryProvider -# or directly: -from agent_framework_azure_cosmos import CosmosHistoryProvider +from azure.identity.aio import DefaultAzureCredential +from agent_framework import WorkflowBuilder +from agent_framework_azure_cosmos import CosmosCheckpointStorage + +checkpoint_storage = CosmosCheckpointStorage( + endpoint="https://.documents.azure.com:443/", + credential=DefaultAzureCredential(), + database_name="agent-framework", + container_name="workflow-checkpoints", +) ``` + +#### Account Key + +```python +from agent_framework_azure_cosmos import CosmosCheckpointStorage + +checkpoint_storage = CosmosCheckpointStorage( + endpoint="https://.documents.azure.com:443/", + credential="", + database_name="agent-framework", + container_name="workflow-checkpoints", +) +``` + +#### Then use with a workflow + +```python +from agent_framework import WorkflowBuilder + +# Build a workflow with checkpointing enabled +workflow = WorkflowBuilder( + start_executor=start, + checkpoint_storage=checkpoint_storage, +).build() + +# Run the workflow — checkpoints are automatically saved after each superstep +result = await workflow.run(message="input data") + +# Resume from a checkpoint +latest = await checkpoint_storage.get_latest(workflow_name=workflow.name) +if latest: + resumed = await workflow.run(checkpoint_id=latest.checkpoint_id) +``` + +### Authentication Options + +`CosmosCheckpointStorage` supports the same authentication modes as `CosmosHistoryProvider`: + +- **Managed identity / RBAC** (recommended): Pass `DefaultAzureCredential()`, + `ManagedIdentityCredential()`, or any Azure `TokenCredential` +- **Account key**: Pass a key string via `credential` parameter +- **Environment variables**: Set `AZURE_COSMOS_ENDPOINT`, `AZURE_COSMOS_DATABASE_NAME`, + `AZURE_COSMOS_CONTAINER_NAME`, and `AZURE_COSMOS_KEY` (key not required when using + Azure credentials) +- **Pre-created client**: Pass an existing `CosmosClient` or `ContainerProxy` + +### Database and Container Setup + +The database and container are created automatically on first use (via +`create_database_if_not_exists` and `create_container_if_not_exists`). The container +uses `/workflow_name` as the partition key. You can also pre-create them in the Azure +portal with this partition key configuration. + +### Environment Variables + +| Variable | Description | +|---|---| +| `AZURE_COSMOS_ENDPOINT` | Cosmos DB account endpoint | +| `AZURE_COSMOS_DATABASE_NAME` | Database name | +| `AZURE_COSMOS_CONTAINER_NAME` | Container name | +| `AZURE_COSMOS_KEY` | Account key (optional if using Azure credentials) | + +See `samples/03-workflows/checkpoint/cosmos_workflow_checkpointing.py` for a standalone example, +or `samples/03-workflows/checkpoint/cosmos_workflow_checkpointing_foundry.py` for an end-to-end +example with Azure AI Foundry agents. diff --git a/python/packages/azure-cosmos/agent_framework_azure_cosmos/__init__.py b/python/packages/azure-cosmos/agent_framework_azure_cosmos/__init__.py index 5bcfb3928b..66373b0f1d 100644 --- a/python/packages/azure-cosmos/agent_framework_azure_cosmos/__init__.py +++ b/python/packages/azure-cosmos/agent_framework_azure_cosmos/__init__.py @@ -2,6 +2,7 @@ import importlib.metadata +from ._checkpoint_storage import CosmosCheckpointStorage from ._history_provider import CosmosHistoryProvider try: @@ -10,6 +11,7 @@ except importlib.metadata.PackageNotFoundError: __version__ = "0.0.0" # Fallback for development mode __all__ = [ + "CosmosCheckpointStorage", "CosmosHistoryProvider", "__version__", ] diff --git a/python/packages/azure-cosmos/agent_framework_azure_cosmos/_checkpoint_storage.py b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_checkpoint_storage.py new file mode 100644 index 0000000000..4544311fd9 --- /dev/null +++ b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_checkpoint_storage.py @@ -0,0 +1,432 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Azure Cosmos DB checkpoint storage for workflow checkpointing.""" + +from __future__ import annotations + +import logging +from typing import Any, TypedDict + +from agent_framework import AGENT_FRAMEWORK_USER_AGENT +from agent_framework._settings import SecretString, load_settings +from agent_framework._workflows._checkpoint import CheckpointID, WorkflowCheckpoint +from agent_framework._workflows._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value +from agent_framework.exceptions import WorkflowCheckpointException +from azure.core.credentials import TokenCredential +from azure.core.credentials_async import AsyncTokenCredential +from azure.cosmos import PartitionKey +from azure.cosmos.aio import ContainerProxy, CosmosClient +from azure.cosmos.exceptions import CosmosResourceNotFoundError + +AzureCredentialTypes = TokenCredential | AsyncTokenCredential + +logger = logging.getLogger(__name__) + + +class AzureCosmosCheckpointSettings(TypedDict, total=False): + """Settings for CosmosCheckpointStorage resolved from args and environment.""" + + endpoint: str | None + database_name: str | None + container_name: str | None + key: SecretString | None + + +class CosmosCheckpointStorage: + """Azure Cosmos DB-backed checkpoint storage for workflow checkpointing. + + Implements the ``CheckpointStorage`` protocol using Azure Cosmos DB NoSQL + as the persistent backend. Checkpoints are stored as JSON documents with + ``workflow_name`` as the partition key, enabling efficient per-workflow queries. + + This storage uses the same hybrid JSON + pickle encoding as + ``FileCheckpointStorage``, allowing full Python object fidelity for + complex workflow state while keeping the document structure human-readable. + + SECURITY WARNING: Checkpoints use pickle for data serialization. Only load + checkpoints from trusted sources. Loading a malicious checkpoint can execute + arbitrary code. + + The database and container are created automatically on first use + if they do not already exist. The container uses partition key + ``/workflow_name``. + + Example using managed identity / RBAC: + + .. code-block:: python + + from azure.identity.aio import DefaultAzureCredential + from agent_framework_azure_cosmos import CosmosCheckpointStorage + + storage = CosmosCheckpointStorage( + endpoint="https://my-account.documents.azure.com:443/", + credential=DefaultAzureCredential(), + database_name="agent-db", + container_name="checkpoints", + ) + + Example using account key: + + .. code-block:: python + + storage = CosmosCheckpointStorage( + endpoint="https://my-account.documents.azure.com:443/", + credential="my-account-key", + database_name="agent-db", + container_name="checkpoints", + ) + + Then use with a workflow builder: + + .. code-block:: python + + workflow = WorkflowBuilder( + start_executor=start, + checkpoint_storage=storage, + ).build() + """ + + def __init__( + self, + *, + endpoint: str | None = None, + database_name: str | None = None, + container_name: str | None = None, + credential: str | AzureCredentialTypes | None = None, + cosmos_client: CosmosClient | None = None, + container_client: ContainerProxy | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize the Azure Cosmos DB checkpoint storage. + + Supports multiple authentication modes: + + - **Container client** (``container_client``): Use a pre-created + Cosmos async container proxy. No client lifecycle is managed. + - **Cosmos client** (``cosmos_client``): Use a pre-created Cosmos + async client. The caller is responsible for closing it. + - **Endpoint + credential**: Create a new Cosmos client. The storage + owns the client and closes it on ``close()``. + - **Environment variables**: Falls back to ``AZURE_COSMOS_ENDPOINT``, + ``AZURE_COSMOS_DATABASE_NAME``, ``AZURE_COSMOS_CONTAINER_NAME``, + and ``AZURE_COSMOS_KEY``. + + Args: + endpoint: Cosmos DB account endpoint. + Can be set via ``AZURE_COSMOS_ENDPOINT``. + database_name: Cosmos DB database name. + Can be set via ``AZURE_COSMOS_DATABASE_NAME``. + container_name: Cosmos DB container name. + Can be set via ``AZURE_COSMOS_CONTAINER_NAME``. + credential: Credential to authenticate with Cosmos DB. + For **managed identity / RBAC**, pass an Azure credential object + such as ``DefaultAzureCredential()`` or + ``ManagedIdentityCredential()``. + For **key-based auth**, pass the account key as a string, + or set ``AZURE_COSMOS_KEY`` in the environment. + cosmos_client: Pre-created Cosmos async client. + container_client: Pre-created Cosmos container client. + env_file_path: Path to environment file for loading settings. + env_file_encoding: Encoding of the environment file. + """ + self._cosmos_client: CosmosClient | None = cosmos_client + self._container_proxy: ContainerProxy | None = container_client + self._owns_client = False + + if self._container_proxy is not None: + self.database_name: str = database_name or "" + self.container_name: str = container_name or "" + return + + required_fields: list[str] = ["database_name", "container_name"] + if cosmos_client is None: + required_fields.append("endpoint") + if credential is None: + required_fields.append("key") + + settings = load_settings( + AzureCosmosCheckpointSettings, + env_prefix="AZURE_COSMOS_", + required_fields=required_fields, + endpoint=endpoint, + database_name=database_name, + container_name=container_name, + key=credential if isinstance(credential, str) else None, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + self.database_name = settings["database_name"] # type: ignore[assignment] + self.container_name = settings["container_name"] # type: ignore[assignment] + + if self._cosmos_client is None: + self._cosmos_client = CosmosClient( + url=settings["endpoint"], # type: ignore[arg-type] + credential=credential or settings["key"].get_secret_value(), # type: ignore[arg-type,union-attr] + user_agent_suffix=AGENT_FRAMEWORK_USER_AGENT, + ) + self._owns_client = True + + async def save(self, checkpoint: WorkflowCheckpoint) -> CheckpointID: + """Save a checkpoint to Cosmos DB and return its ID. + + The checkpoint is encoded to a JSON-compatible form (using pickle for + non-JSON-native values) and stored as a Cosmos DB document with the + ``workflow_name`` as the partition key. + + The document ``id`` is a composite of ``workflow_name`` and + ``checkpoint_id`` to ensure global uniqueness across partitions. + + Args: + checkpoint: The WorkflowCheckpoint object to save. + + Returns: + The unique ID of the saved checkpoint. + """ + await self._ensure_container_proxy() + + checkpoint_dict = checkpoint.to_dict() + encoded = encode_checkpoint_value(checkpoint_dict) + + document: dict[str, Any] = { + "id": self._make_document_id(checkpoint.workflow_name, checkpoint.checkpoint_id), + "workflow_name": checkpoint.workflow_name, + **encoded, + } + + await self._container_proxy.upsert_item(body=document) # type: ignore[union-attr] + logger.info("Saved checkpoint %s to Cosmos DB", checkpoint.checkpoint_id) + return checkpoint.checkpoint_id + + async def load(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint: + """Load a checkpoint from Cosmos DB by ID. + + Args: + checkpoint_id: The unique ID of the checkpoint to load. + + Returns: + The WorkflowCheckpoint object corresponding to the given ID. + + Raises: + WorkflowCheckpointException: If no checkpoint with the given ID exists, + or if multiple checkpoints share the same ID across workflows. + """ + await self._ensure_container_proxy() + + query = "SELECT * FROM c WHERE c.checkpoint_id = @checkpoint_id" + parameters: list[dict[str, object]] = [ + {"name": "@checkpoint_id", "value": checkpoint_id}, + ] + + items = self._container_proxy.query_items( # type: ignore[union-attr] + query=query, + parameters=parameters, + ) + + results: list[dict[str, Any]] = [] + async for item in items: + results.append(item) + + if not results: + raise WorkflowCheckpointException(f"No checkpoint found with ID {checkpoint_id}") + + if len(results) > 1: + workflow_names = [r.get("workflow_name", "unknown") for r in results] + raise WorkflowCheckpointException( + f"Multiple checkpoints found with ID {checkpoint_id} across workflows: " + f"{workflow_names}. Use list_checkpoints(workflow_name=...) to query " + f"by workflow instead." + ) + + return self._document_to_checkpoint(results[0]) + + async def list_checkpoints(self, *, workflow_name: str) -> list[WorkflowCheckpoint]: + """List checkpoint objects for a given workflow name. + + Args: + workflow_name: The name of the workflow to list checkpoints for. + + Returns: + A list of WorkflowCheckpoint objects for the specified workflow name. + """ + await self._ensure_container_proxy() + + query = "SELECT * FROM c WHERE c.workflow_name = @workflow_name ORDER BY c.timestamp ASC" + parameters: list[dict[str, object]] = [ + {"name": "@workflow_name", "value": workflow_name}, + ] + + items = self._container_proxy.query_items( # type: ignore[union-attr] + query=query, + parameters=parameters, + partition_key=workflow_name, + ) + + checkpoints: list[WorkflowCheckpoint] = [] + async for item in items: + try: + checkpoints.append(self._document_to_checkpoint(item)) + except Exception as e: + logger.warning("Failed to decode checkpoint document: %s", e) + return checkpoints + + async def delete(self, checkpoint_id: CheckpointID) -> bool: + """Delete a checkpoint from Cosmos DB by ID. + + Args: + checkpoint_id: The unique ID of the checkpoint to delete. + + Returns: + True if the checkpoint was successfully deleted, False if not found. + """ + await self._ensure_container_proxy() + + query = "SELECT c.id, c.workflow_name FROM c WHERE c.checkpoint_id = @checkpoint_id" + parameters: list[dict[str, object]] = [ + {"name": "@checkpoint_id", "value": checkpoint_id}, + ] + + items = self._container_proxy.query_items( # type: ignore[union-attr] + query=query, + parameters=parameters, + ) + + async for item in items: + try: + await self._container_proxy.delete_item( # type: ignore[union-attr] + item=item["id"], + partition_key=item["workflow_name"], + ) + logger.info("Deleted checkpoint %s from Cosmos DB", checkpoint_id) + return True + except CosmosResourceNotFoundError: + return False + + return False + + async def get_latest(self, *, workflow_name: str) -> WorkflowCheckpoint | None: + """Get the latest checkpoint for a given workflow name. + + Args: + workflow_name: The name of the workflow to get the latest checkpoint for. + + Returns: + The latest WorkflowCheckpoint, or None if no checkpoints exist. + """ + await self._ensure_container_proxy() + + query = ( + "SELECT * FROM c WHERE c.workflow_name = @workflow_name " + "ORDER BY c.timestamp DESC OFFSET 0 LIMIT 1" + ) + parameters: list[dict[str, object]] = [ + {"name": "@workflow_name", "value": workflow_name}, + ] + + items = self._container_proxy.query_items( # type: ignore[union-attr] + query=query, + parameters=parameters, + partition_key=workflow_name, + ) + + async for item in items: + checkpoint = self._document_to_checkpoint(item) + logger.debug( + "Latest checkpoint for workflow %s is %s", + workflow_name, + checkpoint.checkpoint_id, + ) + return checkpoint + + return None + + async def list_checkpoint_ids(self, *, workflow_name: str) -> list[CheckpointID]: + """List checkpoint IDs for a given workflow name. + + Args: + workflow_name: The name of the workflow to list checkpoint IDs for. + + Returns: + A list of checkpoint IDs for the specified workflow name. + """ + await self._ensure_container_proxy() + + query = ( + "SELECT c.checkpoint_id FROM c WHERE c.workflow_name = @workflow_name " + "ORDER BY c.timestamp ASC" + ) + parameters: list[dict[str, object]] = [ + {"name": "@workflow_name", "value": workflow_name}, + ] + + items = self._container_proxy.query_items( # type: ignore[union-attr] + query=query, + parameters=parameters, + partition_key=workflow_name, + ) + + checkpoint_ids: list[CheckpointID] = [] + async for item in items: + cid = item.get("checkpoint_id") + if isinstance(cid, str): + checkpoint_ids.append(cid) + return checkpoint_ids + + async def close(self) -> None: + """Close the underlying Cosmos client when this storage owns it.""" + if self._owns_client and self._cosmos_client is not None: + await self._cosmos_client.close() + + async def __aenter__(self) -> CosmosCheckpointStorage: + """Async context manager entry.""" + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: Any, + ) -> None: + """Async context manager exit.""" + try: + await self.close() + except Exception: + if exc_type is None: + raise + + async def _ensure_container_proxy(self) -> None: + """Get or create the Cosmos DB database and container for storing checkpoints.""" + if self._container_proxy is not None: + return + if self._cosmos_client is None: + raise RuntimeError("Cosmos client is not initialized.") + + database = await self._cosmos_client.create_database_if_not_exists(id=self.database_name) + self._container_proxy = await database.create_container_if_not_exists( + id=self.container_name, + partition_key=PartitionKey(path="/workflow_name"), + ) + + @staticmethod + def _document_to_checkpoint(document: dict[str, Any]) -> WorkflowCheckpoint: + """Convert a Cosmos DB document back to a WorkflowCheckpoint. + + Strips Cosmos DB system properties (``_rid``, ``_self``, ``_etag``, + ``_attachments``, ``_ts``) before decoding. + """ + # Remove Cosmos DB system properties and the composite 'id' field + # (checkpoints use 'checkpoint_id', not 'id') + cosmos_keys = {"id", "_rid", "_self", "_etag", "_attachments", "_ts"} + cleaned = {k: v for k, v in document.items() if k not in cosmos_keys} + + decoded = decode_checkpoint_value(cleaned) + return WorkflowCheckpoint.from_dict(decoded) + + @staticmethod + def _make_document_id(workflow_name: str, checkpoint_id: str) -> str: + """Create a composite Cosmos DB document ID. + + Combines ``workflow_name`` and ``checkpoint_id`` to ensure global + uniqueness across partitions. + """ + return f"{workflow_name}_{checkpoint_id}" diff --git a/python/packages/azure-cosmos/tests/test_cosmos_checkpoint_storage.py b/python/packages/azure-cosmos/tests/test_cosmos_checkpoint_storage.py new file mode 100644 index 0000000000..5e183c3223 --- /dev/null +++ b/python/packages/azure-cosmos/tests/test_cosmos_checkpoint_storage.py @@ -0,0 +1,599 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import os +import uuid +from collections.abc import AsyncIterator +from contextlib import suppress +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from agent_framework._workflows._checkpoint import WorkflowCheckpoint +from agent_framework._workflows._checkpoint_encoding import encode_checkpoint_value +from agent_framework.exceptions import SettingNotFoundError, WorkflowCheckpointException +from azure.cosmos.aio import CosmosClient +from azure.cosmos.exceptions import CosmosResourceNotFoundError + +import agent_framework_azure_cosmos._checkpoint_storage as checkpoint_storage_module +from agent_framework_azure_cosmos._checkpoint_storage import CosmosCheckpointStorage + +skip_if_cosmos_integration_tests_disabled = pytest.mark.skipif( + any( + os.getenv(name, "") == "" + for name in ( + "AZURE_COSMOS_ENDPOINT", + "AZURE_COSMOS_KEY", + "AZURE_COSMOS_DATABASE_NAME", + "AZURE_COSMOS_CONTAINER_NAME", + ) + ), + reason=( + "AZURE_COSMOS_ENDPOINT, AZURE_COSMOS_KEY, AZURE_COSMOS_DATABASE_NAME, and " + "AZURE_COSMOS_CONTAINER_NAME are required for Cosmos integration tests." + ), +) + + +def _to_async_iter(items: list[Any]) -> AsyncIterator[Any]: + async def _iterator() -> AsyncIterator[Any]: + for item in items: + yield item + + return _iterator() + + +def _make_checkpoint( + workflow_name: str = "test-workflow", + checkpoint_id: str | None = None, + previous_checkpoint_id: str | None = None, + timestamp: str | None = None, +) -> WorkflowCheckpoint: + """Create a minimal WorkflowCheckpoint for testing.""" + return WorkflowCheckpoint( + workflow_name=workflow_name, + graph_signature_hash="abc123", + checkpoint_id=checkpoint_id or str(uuid.uuid4()), + previous_checkpoint_id=previous_checkpoint_id, + timestamp=timestamp or "2025-01-01T00:00:00+00:00", + state={"counter": 42}, + iteration_count=1, + ) + + +def _checkpoint_to_cosmos_document(checkpoint: WorkflowCheckpoint) -> dict[str, Any]: + """Simulate what a Cosmos DB document looks like after save.""" + encoded = encode_checkpoint_value(checkpoint.to_dict()) + doc: dict[str, Any] = { + "id": f"{checkpoint.workflow_name}_{checkpoint.checkpoint_id}", + "workflow_name": checkpoint.workflow_name, + **encoded, + # Cosmos system properties + "_rid": "abc", + "_self": "dbs/abc/colls/def/docs/ghi", + "_etag": '"00000000-0000-0000-0000-000000000000"', + "_attachments": "attachments/", + "_ts": 1700000000, + } + return doc + + +@pytest.fixture +def mock_container() -> MagicMock: + container = MagicMock() + container.query_items = MagicMock(return_value=_to_async_iter([])) + container.upsert_item = AsyncMock(return_value={}) + container.delete_item = AsyncMock(return_value={}) + return container + + +@pytest.fixture +def mock_cosmos_client(mock_container: MagicMock) -> MagicMock: + database_client = MagicMock() + database_client.create_container_if_not_exists = AsyncMock(return_value=mock_container) + + client = MagicMock() + client.create_database_if_not_exists = AsyncMock(return_value=database_client) + client.close = AsyncMock() + return client + + +# --- Tests for initialization --- + + +async def test_init_uses_provided_container_client(mock_container: MagicMock) -> None: + storage = CosmosCheckpointStorage(container_client=mock_container) + assert storage.database_name == "" + assert storage.container_name == "" + + +async def test_init_uses_provided_cosmos_client(mock_cosmos_client: MagicMock) -> None: + storage = CosmosCheckpointStorage( + cosmos_client=mock_cosmos_client, + database_name="db1", + container_name="checkpoints", + ) + assert storage.database_name == "db1" + assert storage.container_name == "checkpoints" + + +async def test_init_missing_required_settings_raises(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("AZURE_COSMOS_ENDPOINT", raising=False) + monkeypatch.delenv("AZURE_COSMOS_DATABASE_NAME", raising=False) + monkeypatch.delenv("AZURE_COSMOS_CONTAINER_NAME", raising=False) + monkeypatch.delenv("AZURE_COSMOS_KEY", raising=False) + + with pytest.raises(SettingNotFoundError, match="database_name"): + CosmosCheckpointStorage() + + +async def test_init_constructs_client_with_credential( + monkeypatch: pytest.MonkeyPatch, mock_cosmos_client: MagicMock +) -> None: + """Uses key-based auth when a key string is provided, otherwise falls back to Azure credential (RBAC).""" + mock_factory = MagicMock(return_value=mock_cosmos_client) + monkeypatch.setattr(checkpoint_storage_module, "CosmosClient", mock_factory) + monkeypatch.delenv("AZURE_COSMOS_KEY", raising=False) + + # Simulate real-world pattern: use key if available, else RBAC credential + cosmos_key = os.getenv("AZURE_COSMOS_KEY") + credential: Any = cosmos_key if cosmos_key else MagicMock() # MagicMock simulates DefaultAzureCredential() + + CosmosCheckpointStorage( + endpoint="https://account.documents.azure.com:443/", + credential=credential, + database_name="db1", + container_name="checkpoints", + ) + + mock_factory.assert_called_once() + kwargs = mock_factory.call_args.kwargs + assert kwargs["url"] == "https://account.documents.azure.com:443/" + assert kwargs["credential"] is credential + + +async def test_init_creates_database_and_container(mock_cosmos_client: MagicMock) -> None: + storage = CosmosCheckpointStorage( + cosmos_client=mock_cosmos_client, + database_name="db1", + container_name="custom-checkpoints", + ) + + await storage.list_checkpoint_ids(workflow_name="wf") + + mock_cosmos_client.create_database_if_not_exists.assert_awaited_once_with(id="db1") + database_client = mock_cosmos_client.create_database_if_not_exists.return_value + assert database_client.create_container_if_not_exists.await_count == 1 + kwargs = database_client.create_container_if_not_exists.await_args.kwargs + assert kwargs["id"] == "custom-checkpoints" + + +# --- Tests for save --- + + +async def test_save_upserts_document(mock_container: MagicMock) -> None: + storage = CosmosCheckpointStorage(container_client=mock_container) + checkpoint = _make_checkpoint() + + result = await storage.save(checkpoint) + + assert result == checkpoint.checkpoint_id + mock_container.upsert_item.assert_awaited_once() + document = mock_container.upsert_item.await_args.kwargs["body"] + assert document["id"] == f"test-workflow_{checkpoint.checkpoint_id}" + assert document["workflow_name"] == "test-workflow" + assert document["graph_signature_hash"] == "abc123" + assert document["state"]["counter"] == 42 + + +async def test_save_returns_checkpoint_id(mock_container: MagicMock) -> None: + storage = CosmosCheckpointStorage(container_client=mock_container) + checkpoint = _make_checkpoint(checkpoint_id="cp-123") + + result = await storage.save(checkpoint) + + assert result == "cp-123" + + +# --- Tests for load --- + + +async def test_load_returns_checkpoint(mock_container: MagicMock) -> None: + checkpoint = _make_checkpoint(checkpoint_id="cp-load") + doc = _checkpoint_to_cosmos_document(checkpoint) + mock_container.query_items.return_value = _to_async_iter([doc]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + loaded = await storage.load("cp-load") + + assert loaded.checkpoint_id == "cp-load" + assert loaded.workflow_name == "test-workflow" + assert loaded.graph_signature_hash == "abc123" + assert loaded.state["counter"] == 42 + + +async def test_load_nonexistent_raises(mock_container: MagicMock) -> None: + mock_container.query_items.return_value = _to_async_iter([]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + + with pytest.raises(WorkflowCheckpointException, match="No checkpoint found"): + await storage.load("nonexistent-id") + + +async def test_load_queries_without_partition_key(mock_container: MagicMock) -> None: + mock_container.query_items.return_value = _to_async_iter([]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + with suppress(WorkflowCheckpointException): + await storage.load("cp-id") + + kwargs = mock_container.query_items.call_args.kwargs + assert "partition_key" not in kwargs + + +async def test_load_multiple_workflows_same_checkpoint_id_raises(mock_container: MagicMock) -> None: + cp1 = _make_checkpoint(checkpoint_id="shared-id", workflow_name="workflow-a") + cp2 = _make_checkpoint(checkpoint_id="shared-id", workflow_name="workflow-b") + mock_container.query_items.return_value = _to_async_iter([ + _checkpoint_to_cosmos_document(cp1), + _checkpoint_to_cosmos_document(cp2), + ]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + + with pytest.raises(WorkflowCheckpointException, match="Multiple checkpoints found"): + await storage.load("shared-id") + + +# --- Tests for list_checkpoints --- + + +async def test_list_checkpoints_returns_checkpoints_for_workflow(mock_container: MagicMock) -> None: + cp1 = _make_checkpoint(checkpoint_id="cp-1", timestamp="2025-01-01T00:00:00+00:00") + cp2 = _make_checkpoint(checkpoint_id="cp-2", timestamp="2025-01-02T00:00:00+00:00") + mock_container.query_items.return_value = _to_async_iter([ + _checkpoint_to_cosmos_document(cp1), + _checkpoint_to_cosmos_document(cp2), + ]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + results = await storage.list_checkpoints(workflow_name="test-workflow") + + assert len(results) == 2 + assert results[0].checkpoint_id == "cp-1" + assert results[1].checkpoint_id == "cp-2" + + +async def test_list_checkpoints_uses_partition_key(mock_container: MagicMock) -> None: + mock_container.query_items.return_value = _to_async_iter([]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + await storage.list_checkpoints(workflow_name="my-workflow") + + kwargs = mock_container.query_items.call_args.kwargs + assert kwargs["partition_key"] == "my-workflow" + + +async def test_list_checkpoints_empty_returns_empty(mock_container: MagicMock) -> None: + mock_container.query_items.return_value = _to_async_iter([]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + results = await storage.list_checkpoints(workflow_name="test-workflow") + + assert results == [] + + +async def test_list_checkpoints_skips_malformed_documents(mock_container: MagicMock) -> None: + valid_cp = _make_checkpoint(checkpoint_id="cp-valid") + mock_container.query_items.return_value = _to_async_iter([ + {"id": "bad_doc", "workflow_name": "test-workflow", "not_a_checkpoint": True}, + _checkpoint_to_cosmos_document(valid_cp), + ]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + results = await storage.list_checkpoints(workflow_name="test-workflow") + + assert len(results) == 1 + assert results[0].checkpoint_id == "cp-valid" + + +# --- Tests for delete --- + + +async def test_delete_existing_returns_true(mock_container: MagicMock) -> None: + mock_container.query_items.return_value = _to_async_iter([ + {"id": "test-workflow_cp-del", "workflow_name": "test-workflow"}, + ]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + result = await storage.delete("cp-del") + + assert result is True + mock_container.delete_item.assert_awaited_once_with( + item="test-workflow_cp-del", + partition_key="test-workflow", + ) + + +async def test_delete_nonexistent_returns_false(mock_container: MagicMock) -> None: + mock_container.query_items.return_value = _to_async_iter([]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + result = await storage.delete("nonexistent") + + assert result is False + mock_container.delete_item.assert_not_awaited() + + +async def test_delete_cosmos_not_found_returns_false(mock_container: MagicMock) -> None: + mock_container.query_items.return_value = _to_async_iter([ + {"id": "test-workflow_cp-del", "workflow_name": "test-workflow"}, + ]) + mock_container.delete_item = AsyncMock(side_effect=CosmosResourceNotFoundError) + + storage = CosmosCheckpointStorage(container_client=mock_container) + result = await storage.delete("cp-del") + + assert result is False + + +# --- Tests for get_latest --- + + +async def test_get_latest_returns_latest_checkpoint(mock_container: MagicMock) -> None: + cp = _make_checkpoint(checkpoint_id="cp-latest", timestamp="2025-06-01T00:00:00+00:00") + mock_container.query_items.return_value = _to_async_iter([ + _checkpoint_to_cosmos_document(cp), + ]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + result = await storage.get_latest(workflow_name="test-workflow") + + assert result is not None + assert result.checkpoint_id == "cp-latest" + + +async def test_get_latest_returns_none_when_empty(mock_container: MagicMock) -> None: + mock_container.query_items.return_value = _to_async_iter([]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + result = await storage.get_latest(workflow_name="test-workflow") + + assert result is None + + +async def test_get_latest_uses_order_by_desc_with_limit(mock_container: MagicMock) -> None: + mock_container.query_items.return_value = _to_async_iter([]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + await storage.get_latest(workflow_name="test-workflow") + + kwargs = mock_container.query_items.call_args.kwargs + assert "ORDER BY c.timestamp DESC" in kwargs["query"] + assert "OFFSET 0 LIMIT 1" in kwargs["query"] + + +# --- Tests for list_checkpoint_ids --- + + +async def test_list_checkpoint_ids_returns_ids(mock_container: MagicMock) -> None: + mock_container.query_items.return_value = _to_async_iter([ + {"checkpoint_id": "cp-1"}, + {"checkpoint_id": "cp-2"}, + ]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + ids = await storage.list_checkpoint_ids(workflow_name="test-workflow") + + assert ids == ["cp-1", "cp-2"] + + +async def test_list_checkpoint_ids_empty_returns_empty(mock_container: MagicMock) -> None: + mock_container.query_items.return_value = _to_async_iter([]) + + storage = CosmosCheckpointStorage(container_client=mock_container) + ids = await storage.list_checkpoint_ids(workflow_name="test-workflow") + + assert ids == [] + + +# --- Tests for close and context manager --- + + +async def test_close_closes_owned_client( + monkeypatch: pytest.MonkeyPatch, mock_cosmos_client: MagicMock +) -> None: + mock_factory = MagicMock(return_value=mock_cosmos_client) + monkeypatch.setattr(checkpoint_storage_module, "CosmosClient", mock_factory) + + storage = CosmosCheckpointStorage( + endpoint="https://account.documents.azure.com:443/", + credential="key-123", + database_name="db1", + container_name="checkpoints", + ) + + await storage.close() + + mock_cosmos_client.close.assert_awaited_once() + + +async def test_close_does_not_close_external_client(mock_cosmos_client: MagicMock) -> None: + storage = CosmosCheckpointStorage( + cosmos_client=mock_cosmos_client, + database_name="db1", + container_name="checkpoints", + ) + + await storage.close() + + mock_cosmos_client.close.assert_not_awaited() + + +async def test_context_manager_closes_owned_client( + monkeypatch: pytest.MonkeyPatch, mock_cosmos_client: MagicMock +) -> None: + mock_factory = MagicMock(return_value=mock_cosmos_client) + monkeypatch.setattr(checkpoint_storage_module, "CosmosClient", mock_factory) + + async with CosmosCheckpointStorage( + endpoint="https://account.documents.azure.com:443/", + credential="key-123", + database_name="db1", + container_name="checkpoints", + ) as storage: + assert storage is not None + + mock_cosmos_client.close.assert_awaited_once() + + +async def test_context_manager_preserves_original_exception(mock_container: MagicMock) -> None: + storage = CosmosCheckpointStorage(container_client=mock_container) + + with ( + patch.object(storage, "close", AsyncMock(side_effect=RuntimeError("close failed"))), + pytest.raises(ValueError, match="inner error"), + ): + async with storage: + raise ValueError("inner error") + + +async def test_context_manager_reraises_close_error(mock_container: MagicMock) -> None: + storage = CosmosCheckpointStorage(container_client=mock_container) + + with ( + patch.object(storage, "close", AsyncMock(side_effect=RuntimeError("close failed"))), + pytest.raises(RuntimeError, match="close failed"), + ): + async with storage: + pass # no inner exception — close error should propagate + + +# --- Tests for save/load round-trip --- + + +async def test_round_trip_preserves_data(mock_container: MagicMock) -> None: + checkpoint = _make_checkpoint( + checkpoint_id="cp-roundtrip", + previous_checkpoint_id="cp-parent", + ) + checkpoint.state = {"key": "value", "nested": {"a": 1}} + checkpoint.metadata = {"superstep": 3} + checkpoint.iteration_count = 5 + + saved_doc: dict[str, Any] = {} + + async def capture_upsert(body: dict[str, Any]) -> dict[str, Any]: + saved_doc.update(body) + return body + + mock_container.upsert_item = AsyncMock(side_effect=capture_upsert) + + storage = CosmosCheckpointStorage(container_client=mock_container) + await storage.save(checkpoint) + + returned_doc = { + **saved_doc, + "_rid": "abc", + "_self": "dbs/abc/colls/def/docs/ghi", + "_etag": '"etag"', + "_attachments": "attachments/", + "_ts": 1700000000, + } + mock_container.query_items.return_value = _to_async_iter([returned_doc]) + + loaded = await storage.load("cp-roundtrip") + + assert loaded.checkpoint_id == checkpoint.checkpoint_id + assert loaded.workflow_name == checkpoint.workflow_name + assert loaded.graph_signature_hash == checkpoint.graph_signature_hash + assert loaded.previous_checkpoint_id == "cp-parent" + assert loaded.state == {"key": "value", "nested": {"a": 1}} + assert loaded.metadata == {"superstep": 3} + assert loaded.iteration_count == 5 + assert loaded.version == "1.0" + + +# --- Integration test --- + + +@pytest.mark.integration +@skip_if_cosmos_integration_tests_disabled +async def test_cosmos_checkpoint_storage_roundtrip_with_emulator() -> None: + endpoint = os.getenv("AZURE_COSMOS_ENDPOINT", "") + key = os.getenv("AZURE_COSMOS_KEY", "") + database_prefix = os.getenv("AZURE_COSMOS_DATABASE_NAME", "") + container_prefix = os.getenv("AZURE_COSMOS_CONTAINER_NAME", "") + unique = uuid.uuid4().hex[:8] + database_name = f"{database_prefix}-cp-{unique}" + container_name = f"{container_prefix}-cp-{unique}" + + async with CosmosClient(url=endpoint, credential=key) as cosmos_client: + await cosmos_client.create_database_if_not_exists(id=database_name) + + storage = CosmosCheckpointStorage( + cosmos_client=cosmos_client, + database_name=database_name, + container_name=container_name, + ) + + try: + # Save two checkpoints for the same workflow + cp1 = _make_checkpoint( + checkpoint_id="cp-int-1", + workflow_name="integration-wf", + timestamp="2025-01-01T00:00:00+00:00", + ) + cp2 = _make_checkpoint( + checkpoint_id="cp-int-2", + workflow_name="integration-wf", + previous_checkpoint_id="cp-int-1", + timestamp="2025-01-02T00:00:00+00:00", + ) + cp2.state = {"step": 2} + + await storage.save(cp1) + await storage.save(cp2) + + # Load by ID + loaded = await storage.load("cp-int-1") + assert loaded.checkpoint_id == "cp-int-1" + assert loaded.workflow_name == "integration-wf" + + # List all checkpoints for workflow + all_cps = await storage.list_checkpoints(workflow_name="integration-wf") + assert len(all_cps) == 2 + + # List checkpoint IDs + ids = await storage.list_checkpoint_ids(workflow_name="integration-wf") + assert "cp-int-1" in ids + assert "cp-int-2" in ids + + # Get latest + latest = await storage.get_latest(workflow_name="integration-wf") + assert latest is not None + assert latest.checkpoint_id == "cp-int-2" + assert latest.state == {"step": 2} + + # Delete + assert await storage.delete("cp-int-1") is True + assert await storage.delete("cp-int-1") is False + + remaining = await storage.list_checkpoint_ids(workflow_name="integration-wf") + assert remaining == ["cp-int-2"] + + # Cross-workflow isolation + other_cp = _make_checkpoint( + checkpoint_id="cp-other", + workflow_name="other-wf", + ) + await storage.save(other_cp) + wf_cps = await storage.list_checkpoints(workflow_name="integration-wf") + assert len(wf_cps) == 1 + assert wf_cps[0].checkpoint_id == "cp-int-2" + + finally: + with suppress(Exception): + await cosmos_client.delete_database(database_name) diff --git a/python/samples/02-agents/conversations/README.md b/python/samples/02-agents/conversations/README.md index becacd9134..bbfb078659 100644 --- a/python/samples/02-agents/conversations/README.md +++ b/python/samples/02-agents/conversations/README.md @@ -9,6 +9,9 @@ These samples demonstrate different approaches to managing conversation history | [`suspend_resume_session.py`](suspend_resume_session.py) | Suspend and resume conversation sessions, comparing service-managed sessions (Azure AI Foundry) with in-memory sessions (OpenAI). | | [`custom_history_provider.py`](custom_history_provider.py) | Implement a custom history provider by extending `HistoryProvider`, enabling conversation persistence in your preferred storage backend. | | [`cosmos_history_provider.py`](cosmos_history_provider.py) | Use Azure Cosmos DB as a history provider for durable conversation storage with `CosmosHistoryProvider`. | +| [`cosmos_history_provider_conversation_persistence.py`](cosmos_history_provider_conversation_persistence.py) | Persist and resume conversations across application restarts using `CosmosHistoryProvider` — serialize session state, restore it, and continue with full Cosmos DB history. | +| [`cosmos_history_provider_messages.py`](cosmos_history_provider_messages.py) | Direct message history operations — retrieve stored messages as a transcript, clear session history, and verify data deletion. | +| [`cosmos_history_provider_sessions.py`](cosmos_history_provider_sessions.py) | Multi-session and multi-tenant management — per-tenant session isolation, `list_sessions()` to enumerate, switch between sessions, and resume specific conversations. | | [`redis_history_provider.py`](redis_history_provider.py) | Use Redis as a history provider for persistent conversation history storage across sessions. | ## Prerequisites @@ -22,7 +25,7 @@ These samples demonstrate different approaches to managing conversation history **For `custom_history_provider.py`:** - `OPENAI_API_KEY`: Your OpenAI API key -**For `cosmos_history_provider.py`:** +**For Cosmos DB samples (`cosmos_history_provider*.py`):** - `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint - `FOUNDRY_MODEL`: The Foundry model deployment name - `AZURE_COSMOS_ENDPOINT`: Your Azure Cosmos DB account endpoint diff --git a/python/samples/02-agents/conversations/cosmos_history_provider_conversation_persistence.py b/python/samples/02-agents/conversations/cosmos_history_provider_conversation_persistence.py new file mode 100644 index 0000000000..ef2b444d28 --- /dev/null +++ b/python/samples/02-agents/conversations/cosmos_history_provider_conversation_persistence.py @@ -0,0 +1,165 @@ +# Copyright (c) Microsoft. All rights reserved. +# ruff: noqa: T201 + +import asyncio +import os + +from agent_framework import Agent, AgentSession +from agent_framework.foundry import FoundryChatClient +from agent_framework_azure_cosmos import CosmosHistoryProvider +from azure.identity.aio import AzureCliCredential +from dotenv import load_dotenv + +# Load environment variables from .env file. +load_dotenv() + +""" +This sample demonstrates persisting and resuming conversations across application +restarts using CosmosHistoryProvider as the persistent backend. + +Key components: +- Phase 1: Run a conversation and serialize the session with session.to_dict() +- Phase 2: Simulate an app restart — create new provider and agent instances, + restore the session with AgentSession.from_dict(), and continue the conversation +- Cosmos DB reloads the full message history, so the agent remembers everything + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT + FOUNDRY_MODEL + AZURE_COSMOS_ENDPOINT + AZURE_COSMOS_DATABASE_NAME + AZURE_COSMOS_CONTAINER_NAME +Optional: + AZURE_COSMOS_KEY +""" + + +async def main() -> None: + """Run the conversation persistence sample.""" + project_endpoint = os.getenv("FOUNDRY_PROJECT_ENDPOINT") + model = os.getenv("FOUNDRY_MODEL") + cosmos_endpoint = os.getenv("AZURE_COSMOS_ENDPOINT") + cosmos_database_name = os.getenv("AZURE_COSMOS_DATABASE_NAME") + cosmos_container_name = os.getenv("AZURE_COSMOS_CONTAINER_NAME") + cosmos_key = os.getenv("AZURE_COSMOS_KEY") + + if ( + not project_endpoint + or not model + or not cosmos_endpoint + or not cosmos_database_name + or not cosmos_container_name + ): + print( + "Please set FOUNDRY_PROJECT_ENDPOINT, FOUNDRY_MODEL, " + "AZURE_COSMOS_ENDPOINT, AZURE_COSMOS_DATABASE_NAME, and AZURE_COSMOS_CONTAINER_NAME." + ) + return + + # ── Phase 1: Initial conversation ── + + print("=== Phase 1: Initial conversation ===\n") + + async with ( + AzureCliCredential() as credential, + CosmosHistoryProvider( + endpoint=cosmos_endpoint, + database_name=cosmos_database_name, + container_name=cosmos_container_name, + credential=cosmos_key or credential, + ) as history_provider, + Agent( + client=FoundryChatClient( + project_endpoint=project_endpoint, + model=model, + credential=credential, + ), + name="PersistentAgent", + instructions="You are a helpful assistant that remembers prior turns.", + context_providers=[history_provider], + default_options={"store": False}, + ) as agent, + ): + session = agent.create_session() + + response1 = await agent.run( + "My name is Ada. I'm building a distributed database in Rust.", session=session + ) + print("User: My name is Ada. I'm building a distributed database in Rust.") + print(f"Assistant: {response1.text}\n") + + response2 = await agent.run("The hardest part is the consensus algorithm.", session=session) + print("User: The hardest part is the consensus algorithm.") + print(f"Assistant: {response2.text}\n") + + serialized_session = session.to_dict() + print(f"Session serialized. Session ID: {session.session_id}") + + # ── Phase 2: Simulate app restart ── + + print("\n=== Phase 2: Resuming after 'restart' ===\n") + + async with ( + AzureCliCredential() as credential, + CosmosHistoryProvider( + endpoint=cosmos_endpoint, + database_name=cosmos_database_name, + container_name=cosmos_container_name, + credential=cosmos_key or credential, + ) as history_provider, + Agent( + client=FoundryChatClient( + project_endpoint=project_endpoint, + model=model, + credential=credential, + ), + name="PersistentAgent", + instructions="You are a helpful assistant that remembers prior turns.", + context_providers=[history_provider], + default_options={"store": False}, + ) as agent, + ): + restored_session = AgentSession.from_dict(serialized_session) + print(f"Session restored. Session ID: {restored_session.session_id}\n") + + response3 = await agent.run("What was I working on and what was the challenge?", session=restored_session) + print("User: What was I working on and what was the challenge?") + print(f"Assistant: {response3.text}\n") + + messages = await history_provider.get_messages(restored_session.session_id) + print(f"Messages stored in Cosmos DB: {len(messages)}") + for i, msg in enumerate(messages, 1): + print(f" {i}. [{msg.role}] {msg.text[:80]}...") + + +if __name__ == "__main__": + asyncio.run(main()) + +""" +Sample output: +=== Phase 1: Initial conversation === + +User: My name is Ada. I'm building a distributed database in Rust. +Assistant: That sounds like a great project, Ada! Rust is an excellent choice for ... + +User: The hardest part is the consensus algorithm. +Assistant: Consensus algorithms can be tricky! Are you looking at Raft, Paxos, or ... + +Session serialized. Session ID: + +=== Phase 2: Resuming after 'restart' === + +Session restored. Session ID: + +User: What was I working on and what was the challenge? +Assistant: You told me you're building a distributed database in Rust and that the hardest +part is the consensus algorithm. + +Messages stored in Cosmos DB: 6 + 1. [user] My name is Ada. I'm building a distributed database in Rust.... + 2. [assistant] That sounds like a great project, Ada! Rust is an excellent ch... + 3. [user] The hardest part is the consensus algorithm.... + 4. [assistant] Consensus algorithms can be tricky! Are you looking at Raft, Pa... + 5. [user] What was I working on and what was the challenge?... + 6. [assistant] You told me you're building a distributed database in Rust and ... +""" diff --git a/python/samples/02-agents/conversations/cosmos_history_provider_messages.py b/python/samples/02-agents/conversations/cosmos_history_provider_messages.py new file mode 100644 index 0000000000..9f8e7c9164 --- /dev/null +++ b/python/samples/02-agents/conversations/cosmos_history_provider_messages.py @@ -0,0 +1,157 @@ +# Copyright (c) Microsoft. All rights reserved. +# ruff: noqa: T201 + +import asyncio +import os + +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient +from agent_framework_azure_cosmos import CosmosHistoryProvider +from azure.identity.aio import AzureCliCredential +from dotenv import load_dotenv + +# Load environment variables from .env file. +load_dotenv() + +""" +This sample demonstrates direct message history operations using +CosmosHistoryProvider — retrieving, displaying, and clearing stored messages. + +Key components: +- get_messages(session_id): Retrieve all stored messages as a chat transcript +- clear(session_id): Delete all messages for a session (e.g., GDPR compliance) +- Verifying that history is empty after clearing +- Running a new conversation in the same session after clearing + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT + FOUNDRY_MODEL + AZURE_COSMOS_ENDPOINT + AZURE_COSMOS_DATABASE_NAME + AZURE_COSMOS_CONTAINER_NAME +Optional: + AZURE_COSMOS_KEY +""" + + +async def main() -> None: + """Run the messages history sample.""" + project_endpoint = os.getenv("FOUNDRY_PROJECT_ENDPOINT") + model = os.getenv("FOUNDRY_MODEL") + cosmos_endpoint = os.getenv("AZURE_COSMOS_ENDPOINT") + cosmos_database_name = os.getenv("AZURE_COSMOS_DATABASE_NAME") + cosmos_container_name = os.getenv("AZURE_COSMOS_CONTAINER_NAME") + cosmos_key = os.getenv("AZURE_COSMOS_KEY") + + if ( + not project_endpoint + or not model + or not cosmos_endpoint + or not cosmos_database_name + or not cosmos_container_name + ): + print( + "Please set FOUNDRY_PROJECT_ENDPOINT, FOUNDRY_MODEL, " + "AZURE_COSMOS_ENDPOINT, AZURE_COSMOS_DATABASE_NAME, and AZURE_COSMOS_CONTAINER_NAME." + ) + return + + async with ( + AzureCliCredential() as credential, + CosmosHistoryProvider( + endpoint=cosmos_endpoint, + database_name=cosmos_database_name, + container_name=cosmos_container_name, + credential=cosmos_key or credential, + ) as history_provider, + Agent( + client=FoundryChatClient( + project_endpoint=project_endpoint, + model=model, + credential=credential, + ), + name="HistoryAgent", + instructions="You are a helpful assistant that remembers prior turns.", + context_providers=[history_provider], + default_options={"store": False}, + ) as agent, + ): + session = agent.create_session() + session_id = session.session_id + + # 1. Have a multi-turn conversation. + print("=== Building a conversation ===\n") + + queries = [ + "Hi! My favorite programming language is Python.", + "I also enjoy hiking in the mountains on weekends.", + "What do you know about me so far?", + ] + for query in queries: + response = await agent.run(query, session=session) + print(f"User: {query}") + print(f"Assistant: {response.text}\n") + + # 2. Retrieve and display the full message history as a transcript. + print("=== Chat transcript from Cosmos DB ===\n") + + messages = await history_provider.get_messages(session_id) + print(f"Total messages stored: {len(messages)}\n") + for i, msg in enumerate(messages, 1): + print(f" {i}. [{msg.role}] {msg.text[:100]}") + + # 3. Clear the session history. + print("\n=== Clearing session history ===\n") + + await history_provider.clear(session_id) + print(f"Cleared all messages for session: {session_id}") + + # 4. Verify history is empty. + remaining = await history_provider.get_messages(session_id) + print(f"Messages after clear: {len(remaining)}") + + # 5. Start a fresh conversation in the same session — agent has no memory. + print("\n=== Fresh conversation (same session, no memory) ===\n") + + response = await agent.run("What do you know about me?", session=session) + print("User: What do you know about me?") + print(f"Assistant: {response.text}") + + +if __name__ == "__main__": + asyncio.run(main()) + +""" +Sample output: +=== Building a conversation === + +User: Hi! My favorite programming language is Python. +Assistant: That's great! Python is a wonderful language. What do you like most about it? + +User: I also enjoy hiking in the mountains on weekends. +Assistant: Hiking sounds lovely! Do you have a favorite trail or mountain range? + +User: What do you know about me so far? +Assistant: You love Python as your favorite programming language and enjoy hiking in the mountains on weekends. + +=== Chat transcript from Cosmos DB === + +Total messages stored: 6 + + 1. [user] Hi! My favorite programming language is Python. + 2. [assistant] That's great! Python is a wonderful language. What do you like most about it? + 3. [user] I also enjoy hiking in the mountains on weekends. + 4. [assistant] Hiking sounds lovely! Do you have a favorite trail or mountain range? + 5. [user] What do you know about me so far? + 6. [assistant] You love Python as your favorite programming language and enjoy hiking ... + +=== Clearing session history === + +Cleared all messages for session: +Messages after clear: 0 + +=== Fresh conversation (same session, no memory) === + +User: What do you know about me? +Assistant: I don't have any information about you yet. Feel free to share anything you'd like! +""" diff --git a/python/samples/02-agents/conversations/cosmos_history_provider_sessions.py b/python/samples/02-agents/conversations/cosmos_history_provider_sessions.py new file mode 100644 index 0000000000..2d1861e503 --- /dev/null +++ b/python/samples/02-agents/conversations/cosmos_history_provider_sessions.py @@ -0,0 +1,197 @@ +# Copyright (c) Microsoft. All rights reserved. +# ruff: noqa: T201 + +import asyncio +import os + +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient +from agent_framework_azure_cosmos import CosmosHistoryProvider +from azure.identity.aio import AzureCliCredential +from dotenv import load_dotenv + +# Load environment variables from .env file. +load_dotenv() + +""" +This sample demonstrates multi-session and multi-tenant management using +CosmosHistoryProvider. Each tenant (user) gets isolated conversation sessions +stored in the same Cosmos DB container, partitioned by session_id. + +Key components: +- Per-tenant session isolation using prefixed session IDs +- list_sessions(): Enumerate all stored sessions across tenants +- Switching between sessions for different users +- Resuming a specific user's session — verifying data isolation + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT + FOUNDRY_MODEL + AZURE_COSMOS_ENDPOINT + AZURE_COSMOS_DATABASE_NAME + AZURE_COSMOS_CONTAINER_NAME +Optional: + AZURE_COSMOS_KEY +""" + + +async def main() -> None: + """Run the session management sample.""" + project_endpoint = os.getenv("FOUNDRY_PROJECT_ENDPOINT") + model = os.getenv("FOUNDRY_MODEL") + cosmos_endpoint = os.getenv("AZURE_COSMOS_ENDPOINT") + cosmos_database_name = os.getenv("AZURE_COSMOS_DATABASE_NAME") + cosmos_container_name = os.getenv("AZURE_COSMOS_CONTAINER_NAME") + cosmos_key = os.getenv("AZURE_COSMOS_KEY") + + if ( + not project_endpoint + or not model + or not cosmos_endpoint + or not cosmos_database_name + or not cosmos_container_name + ): + print( + "Please set FOUNDRY_PROJECT_ENDPOINT, FOUNDRY_MODEL, " + "AZURE_COSMOS_ENDPOINT, AZURE_COSMOS_DATABASE_NAME, and AZURE_COSMOS_CONTAINER_NAME." + ) + return + + async with ( + AzureCliCredential() as credential, + CosmosHistoryProvider( + endpoint=cosmos_endpoint, + database_name=cosmos_database_name, + container_name=cosmos_container_name, + credential=cosmos_key or credential, + ) as history_provider, + Agent( + client=FoundryChatClient( + project_endpoint=project_endpoint, + model=model, + credential=credential, + ), + name="MultiTenantAgent", + instructions="You are a helpful assistant that remembers prior turns.", + context_providers=[history_provider], + default_options={"store": False}, + ) as agent, + ): + # 1. Tenant "alice" starts a conversation about travel. + print("=== Tenant: Alice — Travel conversation ===\n") + + alice_session = agent.create_session(session_id="tenant-alice-session-1") + + response = await agent.run( + "Hi! I'm planning a trip to Italy. I love Renaissance art.", session=alice_session + ) + print("Alice: I'm planning a trip to Italy. I love Renaissance art.") + print(f"Assistant: {response.text}\n") + + response = await agent.run("Which museums should I visit in Florence?", session=alice_session) + print("Alice: Which museums should I visit in Florence?") + print(f"Assistant: {response.text}\n") + + # 2. Tenant "bob" starts a separate conversation about cooking. + print("=== Tenant: Bob — Cooking conversation ===\n") + + bob_session = agent.create_session(session_id="tenant-bob-session-1") + + response = await agent.run( + "Hey! I'm learning to cook Thai food. I just made pad thai.", session=bob_session + ) + print("Bob: I'm learning to cook Thai food. I just made pad thai.") + print(f"Assistant: {response.text}\n") + + response = await agent.run("What Thai dish should I try next?", session=bob_session) + print("Bob: What Thai dish should I try next?") + print(f"Assistant: {response.text}\n") + + # 3. List all sessions stored in Cosmos DB. + print("=== Listing all sessions ===\n") + + sessions = await history_provider.list_sessions() + print(f"Found {len(sessions)} session(s):") + for sid in sessions: + print(f" - {sid}") + + # 4. Resume Alice's session — verify she gets her travel context back. + print("\n=== Resuming Alice's session ===\n") + + alice_resumed = agent.create_session(session_id="tenant-alice-session-1") + + response = await agent.run("What were we discussing?", session=alice_resumed) + print("Alice: What were we discussing?") + print(f"Assistant: {response.text}\n") + + # 5. Resume Bob's session — verify he gets his cooking context back. + print("=== Resuming Bob's session ===\n") + + bob_resumed = agent.create_session(session_id="tenant-bob-session-1") + + response = await agent.run("What was the last dish I mentioned?", session=bob_resumed) + print("Bob: What was the last dish I mentioned?") + print(f"Assistant: {response.text}\n") + + # 6. Show per-session message counts. + print("=== Per-session message counts ===\n") + + alice_messages = await history_provider.get_messages("tenant-alice-session-1") + bob_messages = await history_provider.get_messages("tenant-bob-session-1") + print(f"Alice's session: {len(alice_messages)} messages") + print(f"Bob's session: {len(bob_messages)} messages") + + # 7. Clean up: clear both sessions. + print("\n=== Cleaning up ===\n") + + await history_provider.clear("tenant-alice-session-1") + await history_provider.clear("tenant-bob-session-1") + print("Cleared Alice's and Bob's sessions.") + + +if __name__ == "__main__": + asyncio.run(main()) + +""" +Sample output: +=== Tenant: Alice — Travel conversation === + +Alice: I'm planning a trip to Italy. I love Renaissance art. +Assistant: Italy is a dream for Renaissance art lovers! Florence, Rome, and Venice ... + +Alice: Which museums should I visit in Florence? +Assistant: In Florence, the Uffizi Gallery is a must — it has Botticelli's Birth of Venus ... + +=== Tenant: Bob — Cooking conversation === + +Bob: I'm learning to cook Thai food. I just made pad thai. +Assistant: Pad thai is a great start! How did it turn out? + +Bob: What Thai dish should I try next? +Assistant: I'd suggest trying green curry or tom yum soup — both are classic Thai dishes ... + +=== Listing all sessions === + +Found 2 session(s): + - tenant-alice-session-1 + - tenant-bob-session-1 + +=== Resuming Alice's session === + +Alice: What were we discussing? +Assistant: We were discussing your trip to Italy and your love for Renaissance art ... + +=== Resuming Bob's session === + +Bob: What was the last dish I mentioned? +Assistant: You mentioned pad thai — it was the dish you just made! + +=== Per-session message counts === + +Alice's session: 6 messages +Bob's session: 6 messages + +=== Cleaning up === + +Cleared Alice's and Bob's sessions. +""" diff --git a/python/samples/03-workflows/README.md b/python/samples/03-workflows/README.md index 1fd5ab01fc..ae3292a07a 100644 --- a/python/samples/03-workflows/README.md +++ b/python/samples/03-workflows/README.md @@ -52,6 +52,8 @@ Once comfortable with these, explore the rest of the samples below. | Checkpointed Sub-Workflow | [checkpoint/sub_workflow_checkpoint.py](./checkpoint/sub_workflow_checkpoint.py) | Save and resume a sub-workflow that pauses for human approval | | Handoff + Tool Approval Resume | [orchestrations/handoff_with_tool_approval_checkpoint_resume.py](./orchestrations/handoff_with_tool_approval_checkpoint_resume.py) | Handoff workflow that captures tool-call approvals in checkpoints and resumes with human decisions | | Workflow as Agent Checkpoint | [checkpoint/workflow_as_agent_checkpoint.py](./checkpoint/workflow_as_agent_checkpoint.py) | Enable checkpointing when using workflow.as_agent() with checkpoint_storage parameter | +| Cosmos DB Checkpoint Storage | [checkpoint/cosmos_workflow_checkpointing.py](./checkpoint/cosmos_workflow_checkpointing.py) | Use `CosmosCheckpointStorage` for durable workflow checkpointing backed by Azure Cosmos DB NoSQL | +| Cosmos DB + Foundry Checkpoint | [checkpoint/cosmos_workflow_checkpointing_foundry.py](./checkpoint/cosmos_workflow_checkpointing_foundry.py) | Multi-agent workflow using `FoundryChatClient` with `CosmosCheckpointStorage` for durable pause/resume | ### composition diff --git a/python/samples/03-workflows/checkpoint/cosmos_workflow_checkpointing.py b/python/samples/03-workflows/checkpoint/cosmos_workflow_checkpointing.py new file mode 100644 index 0000000000..4726742ffc --- /dev/null +++ b/python/samples/03-workflows/checkpoint/cosmos_workflow_checkpointing.py @@ -0,0 +1,201 @@ +# Copyright (c) Microsoft. All rights reserved. +# ruff: noqa: T201 + +"""Sample: Workflow Checkpointing with Cosmos DB NoSQL. + +Purpose: +This sample shows how to use Azure Cosmos DB NoSQL as a persistent checkpoint +storage backend for workflows, enabling durable pause-and-resume across +process restarts. + +What you learn: +- How to configure CosmosCheckpointStorage for workflow checkpointing +- How to run a workflow that automatically persists checkpoints to Cosmos DB +- How to resume a workflow from a Cosmos DB checkpoint +- How to list and inspect available checkpoints + +Prerequisites: +- An Azure Cosmos DB account (or local emulator) +- Environment variables set (see below) + +Environment variables: + AZURE_COSMOS_ENDPOINT - Cosmos DB account endpoint + AZURE_COSMOS_DATABASE_NAME - Database name + AZURE_COSMOS_CONTAINER_NAME - Container name for checkpoints +Optional: + AZURE_COSMOS_KEY - Account key (if not using Azure credentials) +""" + +import asyncio +import os +import sys +from dataclasses import dataclass +from typing import Any + +from agent_framework import ( + Executor, + WorkflowBuilder, + WorkflowCheckpoint, + WorkflowContext, + handler, +) + +if sys.version_info >= (3, 12): + from typing import override # type: ignore # pragma: no cover +else: + from typing_extensions import override # type: ignore[import] # pragma: no cover + +from agent_framework_azure_cosmos import CosmosCheckpointStorage + + +@dataclass +class ComputeTask: + """Task containing the list of numbers remaining to be processed.""" + + remaining_numbers: list[int] + + +class StartExecutor(Executor): + """Initiates the workflow by providing the upper limit.""" + + @handler + async def start(self, upper_limit: int, ctx: WorkflowContext[ComputeTask]) -> None: + """Start the workflow with numbers up to the given limit.""" + print(f"StartExecutor: Starting computation up to {upper_limit}") + await ctx.send_message(ComputeTask(remaining_numbers=list(range(1, upper_limit + 1)))) + + +class WorkerExecutor(Executor): + """Processes numbers and manages executor state for checkpointing.""" + + def __init__(self, id: str) -> None: + """Initialize the worker executor.""" + super().__init__(id=id) + self._results: dict[int, list[tuple[int, int]]] = {} + + @handler + async def compute( + self, + task: ComputeTask, + ctx: WorkflowContext[ComputeTask, dict[int, list[tuple[int, int]]]], + ) -> None: + """Process the next number, computing its factor pairs.""" + next_number = task.remaining_numbers.pop(0) + print(f"WorkerExecutor: Processing {next_number}") + + pairs: list[tuple[int, int]] = [] + for i in range(1, next_number): + if next_number % i == 0: + pairs.append((i, next_number // i)) + self._results[next_number] = pairs + + if not task.remaining_numbers: + await ctx.yield_output(self._results) + else: + await ctx.send_message(task) + + @override + async def on_checkpoint_save(self) -> dict[str, Any]: + return {"results": self._results} + + @override + async def on_checkpoint_restore(self, state: dict[str, Any]) -> None: + self._results = state.get("results", {}) + + +async def main() -> None: + """Run the workflow checkpointing sample with Cosmos DB.""" + cosmos_endpoint = os.getenv("AZURE_COSMOS_ENDPOINT") + cosmos_database_name = os.getenv("AZURE_COSMOS_DATABASE_NAME") + cosmos_container_name = os.getenv("AZURE_COSMOS_CONTAINER_NAME") + cosmos_key = os.getenv("AZURE_COSMOS_KEY") + + if not cosmos_endpoint or not cosmos_database_name or not cosmos_container_name: + print( + "Please set AZURE_COSMOS_ENDPOINT, AZURE_COSMOS_DATABASE_NAME, " + "and AZURE_COSMOS_CONTAINER_NAME." + ) + return + + # Authentication: supports both managed identity/RBAC and key-based auth. + # When AZURE_COSMOS_KEY is set, key-based auth is used. + # Otherwise, falls back to DefaultAzureCredential (properly closed via async with). + if cosmos_key: + async with CosmosCheckpointStorage( + endpoint=cosmos_endpoint, + credential=cosmos_key, + database_name=cosmos_database_name, + container_name=cosmos_container_name, + ) as checkpoint_storage: + await _run_workflow(checkpoint_storage) + else: + from azure.identity.aio import DefaultAzureCredential + + async with DefaultAzureCredential() as credential, CosmosCheckpointStorage( + endpoint=cosmos_endpoint, + credential=credential, + database_name=cosmos_database_name, + container_name=cosmos_container_name, + ) as checkpoint_storage: + await _run_workflow(checkpoint_storage) + + +async def _run_workflow(checkpoint_storage: CosmosCheckpointStorage) -> None: + """Build and run the workflow with Cosmos DB checkpointing.""" + start = StartExecutor(id="start") + worker = WorkerExecutor(id="worker") + workflow_builder = ( + WorkflowBuilder(start_executor=start, checkpoint_storage=checkpoint_storage) + .add_edge(start, worker) + .add_edge(worker, worker) + ) + + # --- First run: execute the workflow --- + print("\n=== First Run ===\n") + workflow = workflow_builder.build() + + output = None + async for event in workflow.run(message=8, stream=True): + if event.type == "output": + output = event.data + + print(f"Factor pairs computed: {output}") + + # List checkpoints saved in Cosmos DB + checkpoint_ids = await checkpoint_storage.list_checkpoint_ids( + workflow_name=workflow.name, + ) + print(f"\nCheckpoints in Cosmos DB: {len(checkpoint_ids)}") + for cid in checkpoint_ids: + print(f" - {cid}") + + # Get the latest checkpoint + latest: WorkflowCheckpoint | None = await checkpoint_storage.get_latest( + workflow_name=workflow.name, + ) + + if latest is None: + print("No checkpoint found to resume from.") + return + + print(f"\nLatest checkpoint: {latest.checkpoint_id}") + print(f" iteration_count: {latest.iteration_count}") + print(f" timestamp: {latest.timestamp}") + + # --- Second run: resume from the latest checkpoint --- + print("\n=== Resuming from Checkpoint ===\n") + workflow2 = workflow_builder.build() + + output2 = None + async for event in workflow2.run(checkpoint_id=latest.checkpoint_id, stream=True): + if event.type == "output": + output2 = event.data + + if output2: + print(f"Resumed workflow produced: {output2}") + else: + print("Resumed workflow completed (no remaining work — already finished).") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/03-workflows/checkpoint/cosmos_workflow_checkpointing_foundry.py b/python/samples/03-workflows/checkpoint/cosmos_workflow_checkpointing_foundry.py new file mode 100644 index 0000000000..49c3e779f9 --- /dev/null +++ b/python/samples/03-workflows/checkpoint/cosmos_workflow_checkpointing_foundry.py @@ -0,0 +1,144 @@ +# Copyright (c) Microsoft. All rights reserved. +# ruff: noqa: T201 + +"""Sample: Workflow Checkpointing with Cosmos DB and Azure AI Foundry. + +Purpose: +This sample demonstrates how to use CosmosCheckpointStorage with agents built +on Azure AI Foundry (via FoundryChatClient). It shows a multi-agent +workflow where checkpoint state is persisted to Cosmos DB, enabling durable +pause-and-resume across process restarts. + +What you learn: +- How to wire CosmosCheckpointStorage with FoundryChatClient agents +- How to combine session history with workflow checkpointing +- How to resume a workflow-as-agent from a Cosmos DB checkpoint + +Key concepts: +- AgentSession: Maintains conversation history across agent invocations +- CosmosCheckpointStorage: Persists workflow execution state in Cosmos DB +- These are complementary: sessions track conversation, checkpoints track workflow state + +Environment variables: + FOUNDRY_PROJECT_ENDPOINT - Azure AI Foundry project endpoint + FOUNDRY_MODEL - Model deployment name + AZURE_COSMOS_ENDPOINT - Cosmos DB account endpoint + AZURE_COSMOS_DATABASE_NAME - Database name + AZURE_COSMOS_CONTAINER_NAME - Container name for checkpoints +Optional: + AZURE_COSMOS_KEY - Account key (if not using Azure credentials) +""" + +import asyncio +import os +from typing import Any + +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient +from agent_framework.orchestrations import SequentialBuilder +from agent_framework_azure_cosmos import CosmosCheckpointStorage +from azure.identity.aio import AzureCliCredential +from dotenv import load_dotenv + +load_dotenv() + + +async def main() -> None: + """Run the Azure AI Foundry + Cosmos DB checkpointing sample.""" + project_endpoint = os.getenv("FOUNDRY_PROJECT_ENDPOINT") + model = os.getenv("FOUNDRY_MODEL") + cosmos_endpoint = os.getenv("AZURE_COSMOS_ENDPOINT") + cosmos_database_name = os.getenv("AZURE_COSMOS_DATABASE_NAME") + cosmos_container_name = os.getenv("AZURE_COSMOS_CONTAINER_NAME") + cosmos_key = os.getenv("AZURE_COSMOS_KEY") + + if not project_endpoint or not model: + print("Please set FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL.") + return + + if not cosmos_endpoint or not cosmos_database_name or not cosmos_container_name: + print( + "Please set AZURE_COSMOS_ENDPOINT, AZURE_COSMOS_DATABASE_NAME, " + "and AZURE_COSMOS_CONTAINER_NAME." + ) + return + + # Use a single AzureCliCredential for both Cosmos and Foundry, + # properly closed via async context manager. + async with AzureCliCredential() as azure_credential: + cosmos_credential: Any = cosmos_key if cosmos_key else azure_credential + + async with CosmosCheckpointStorage( + endpoint=cosmos_endpoint, + credential=cosmos_credential, + database_name=cosmos_database_name, + container_name=cosmos_container_name, + ) as checkpoint_storage: + # Create Azure AI Foundry agents + client = FoundryChatClient( + project_endpoint=project_endpoint, + model=model, + credential=azure_credential, + ) + + assistant = Agent( + name="assistant", + instructions="You are a helpful assistant. Keep responses brief.", + client=client, + ) + + reviewer = Agent( + name="reviewer", + instructions="You are a reviewer. Provide a one-sentence summary of the assistant's response.", + client=client, + ) + + # Build a sequential workflow and wrap it as an agent + workflow = SequentialBuilder(participants=[assistant, reviewer]).build() + agent = workflow.as_agent(name="FoundryCheckpointedAgent") + + # --- First run: execute with Cosmos DB checkpointing --- + print("=== First Run ===\n") + + session = agent.create_session() + query = "What are the benefits of renewable energy?" + print(f"User: {query}") + + response = await agent.run(query, session=session, checkpoint_storage=checkpoint_storage) + + for msg in response.messages: + speaker = msg.author_name or msg.role + print(f"[{speaker}]: {msg.text}") + + # Show checkpoints persisted in Cosmos DB + checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow.name) + print(f"\nCheckpoints in Cosmos DB: {len(checkpoints)}") + for i, cp in enumerate(checkpoints[:5], 1): + print(f" {i}. {cp.checkpoint_id} (iteration={cp.iteration_count})") + + # --- Second run: continue conversation with checkpoint history --- + print("\n=== Second Run (continuing conversation) ===\n") + + query2 = "Can you elaborate on the economic benefits?" + print(f"User: {query2}") + + response2 = await agent.run(query2, session=session, checkpoint_storage=checkpoint_storage) + + for msg in response2.messages: + speaker = msg.author_name or msg.role + print(f"[{speaker}]: {msg.text}") + + # Show total checkpoints + all_checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow.name) + print(f"\nTotal checkpoints after two runs: {len(all_checkpoints)}") + + # Get latest checkpoint + latest = await checkpoint_storage.get_latest(workflow_name=workflow.name) + if latest: + print(f"Latest checkpoint: {latest.checkpoint_id}") + print(f" iteration_count: {latest.iteration_count}") + print(f" timestamp: {latest.timestamp}") + + +if __name__ == "__main__": + asyncio.run(main()) From 79afda1a6c4103baa5ae3a42b3004a9e1d28f892 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:46:20 +0100 Subject: [PATCH 03/22] Samples fixes (#5169) --- dotnet/eng/verify-samples/AgentsSamples.cs | 4 ++-- .../FileSystemJsonCheckpointStore.cs | 2 +- .../FileSystemJsonCheckpointStoreTests.cs | 19 +++++++++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/dotnet/eng/verify-samples/AgentsSamples.cs b/dotnet/eng/verify-samples/AgentsSamples.cs index f44dd2f1f6..7a1ab8eaff 100644 --- a/dotnet/eng/verify-samples/AgentsSamples.cs +++ b/dotnet/eng/verify-samples/AgentsSamples.cs @@ -246,7 +246,7 @@ internal static class AgentsSamples ExpectedOutputDescription = [ "The output should contain information about both the current time and the weather in Seattle.", - "The weather information should reference the plugin result: cloudy with a high of 15°C.", + "The weather information should be similar to: cloudy with a high of 15°C. Exact phrasing may vary.", "The output should not contain error messages or stack traces.", ], }, @@ -521,7 +521,7 @@ internal static class AgentsSamples OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], ExpectedOutputDescription = [ - "The output should demonstrate server-side conversation sessions with non-streaming and streaming turns.", + "The output should contain multiple joke responses showing a multi-turn conversation.", "The output should not contain error messages or stack traces.", ], }, diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/FileSystemJsonCheckpointStore.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/FileSystemJsonCheckpointStore.cs index c47298f112..9a2ecd8c23 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/FileSystemJsonCheckpointStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/FileSystemJsonCheckpointStore.cs @@ -168,7 +168,7 @@ public sealed class FileSystemJsonCheckpointStore : JsonCheckpointStore, IDispos string filePath = Path.Combine(this.Directory.FullName, fileName); if (!this.CheckpointIndex.Contains(key) || - !File.Exists(fileName)) + !File.Exists(filePath)) { throw new KeyNotFoundException($"Checkpoint '{key.CheckpointId}' not found in store at '{this.Directory.FullName}'."); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/FileSystemJsonCheckpointStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/FileSystemJsonCheckpointStoreTests.cs index 10fbfddd64..f0058e7390 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/FileSystemJsonCheckpointStoreTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/FileSystemJsonCheckpointStoreTests.cs @@ -178,4 +178,23 @@ public sealed class FileSystemJsonCheckpointStoreTests Func createCheckpointAction = async () => await store.CreateCheckpointAsync(runId, TestData); await createCheckpointAction.Should().NotThrowAsync(); } + + [Fact] + public async Task RetrieveCheckpointAsync_ShouldReturnPersistedDataAsync() + { + // Arrange + using TempDirectory tempDirectory = new(); + using FileSystemJsonCheckpointStore store = new(tempDirectory); + + string sessionId = Guid.NewGuid().ToString("N"); + JsonElement originalData = JsonSerializer.SerializeToElement(new { name = "test", value = 42 }); + + // Act + CheckpointInfo checkpoint = await store.CreateCheckpointAsync(sessionId, originalData); + JsonElement retrieved = await store.RetrieveCheckpointAsync(sessionId, checkpoint); + + // Assert + retrieved.GetProperty("name").GetString().Should().Be("test"); + retrieved.GetProperty("value").GetInt32().Should().Be(42); + } } From 6d6cb840aec8b85c6bb5e95dc680c8fdd6110394 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:25:00 +0100 Subject: [PATCH 04/22] .NET: Improve resilience of verify-samples by building separately and improving evaluation instructions (#5151) * Improve resilience of verify-samples by building separately and improving evaluation instructions * Address PR comments * Address PR comment --- .github/workflows/dotnet-verify-samples.yml | 5 ++ .../skills/verify-samples-tool/SKILL.md | 11 +++++ dotnet/eng/verify-samples/Program.cs | 5 +- dotnet/eng/verify-samples/SampleRunner.cs | 13 ++++- dotnet/eng/verify-samples/SampleVerifier.cs | 49 ++++++++++++++----- .../VerificationOrchestrator.cs | 9 ++-- dotnet/eng/verify-samples/VerifyOptions.cs | 20 ++++++++ 7 files changed, 95 insertions(+), 17 deletions(-) diff --git a/.github/workflows/dotnet-verify-samples.yml b/.github/workflows/dotnet-verify-samples.yml index ad384eb83e..b1c13a275f 100644 --- a/.github/workflows/dotnet-verify-samples.yml +++ b/.github/workflows/dotnet-verify-samples.yml @@ -63,6 +63,11 @@ jobs: tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + - name: Build solution + working-directory: dotnet + shell: bash + run: dotnet build agent-framework-dotnet.slnx -f net10.0 --warnaserror + - name: Run verify-samples id: verify working-directory: dotnet diff --git a/dotnet/.github/skills/verify-samples-tool/SKILL.md b/dotnet/.github/skills/verify-samples-tool/SKILL.md index cbb1b35009..4d4f153bfd 100644 --- a/dotnet/.github/skills/verify-samples-tool/SKILL.md +++ b/dotnet/.github/skills/verify-samples-tool/SKILL.md @@ -9,9 +9,16 @@ The `verify-samples` project (`dotnet/eng/verify-samples/`) is an automated tool ## Running verify-samples +**Important:** By default, samples must be pre-built before running verify-samples. Build the solution first, or pass `--build` to build samples during the run: + ```bash cd dotnet +dotnet build agent-framework-dotnet.slnx -f net10.0 +``` +Then run verify-samples: + +```bash # Run all samples across all categories dotnet run --project eng/verify-samples -- --log results.log --csv results.csv @@ -24,6 +31,10 @@ dotnet run --project eng/verify-samples -- Agent_Step02_StructuredOutput Agent_S # Control parallelism (default 8) dotnet run --project eng/verify-samples -- --parallel 8 --log results.log +# Build samples during run (skips the need for a prior build step) +# This may cause build conflicts as multiple samples are built in parallel, so use with caution +dotnet run --project eng/verify-samples -- --build --log results.log + # Combine options dotnet run --project eng/verify-samples -- --category 03-workflows --parallel 4 --log results.log --csv results.csv --md results.md ``` diff --git a/dotnet/eng/verify-samples/Program.cs b/dotnet/eng/verify-samples/Program.cs index 7f27d37dd5..ebddc4b16b 100644 --- a/dotnet/eng/verify-samples/Program.cs +++ b/dotnet/eng/verify-samples/Program.cs @@ -14,6 +14,9 @@ // dotnet run -- --log results.log # Write sequential log to file // dotnet run -- --csv results.csv # Write CSV summary to file // dotnet run -- --md results.md # Write Markdown summary to file +// dotnet run -- --build # Build samples during run (default: --no-build) +// Note: By default, this tool expects sample build outputs to already exist. +// Pre-build the solution before running, or pass --build to avoid missing build output failures. // // Required environment variables (for AI-powered samples): // AZURE_OPENAI_ENDPOINT @@ -63,7 +66,7 @@ try // Run all samples var reporter = new ConsoleReporter(); var verifier = new SampleVerifier(chatClient); - var orchestrator = new VerificationOrchestrator(verifier, reporter, dotnetRoot, TimeSpan.FromMinutes(3), logWriter); + var orchestrator = new VerificationOrchestrator(verifier, reporter, dotnetRoot, TimeSpan.FromMinutes(3), logWriter, buildSamples: options.BuildSamples); var run = await orchestrator.RunAllAsync(options.Samples, options.MaxParallelism); diff --git a/dotnet/eng/verify-samples/SampleRunner.cs b/dotnet/eng/verify-samples/SampleRunner.cs index 0fabd82262..f8bd3cc0e6 100644 --- a/dotnet/eng/verify-samples/SampleRunner.cs +++ b/dotnet/eng/verify-samples/SampleRunner.cs @@ -20,23 +20,32 @@ internal static class SampleRunner { /// /// Runs dotnet run --framework net10.0 in the given project directory. + /// When is false (the default), --no-build is passed + /// to skip building, assuming the project was pre-built. /// public static Task RunAsync( string projectPath, TimeSpan timeout, + bool build = false, CancellationToken cancellationToken = default) - => RunAsync(projectPath, "run --framework net10.0", timeout, inputs: null, inputDelayMs: 0, cancellationToken: cancellationToken); + => RunAsync(projectPath, DotnetRunArgs(build), timeout, inputs: null, inputDelayMs: 0, cancellationToken: cancellationToken); /// /// Runs dotnet run --framework net10.0 with stdin inputs. + /// When is false (the default), --no-build is passed + /// to skip building, assuming the project was pre-built. /// public static Task RunAsync( string projectPath, TimeSpan timeout, string?[]? inputs, int inputDelayMs = 2000, + bool build = false, CancellationToken cancellationToken = default) - => RunAsync(projectPath, "run --framework net10.0", timeout, inputs, inputDelayMs, cancellationToken); + => RunAsync(projectPath, DotnetRunArgs(build), timeout, inputs, inputDelayMs, cancellationToken); + + private static string DotnetRunArgs(bool build) => + $"run {(build ? "" : "--no-build")} --framework net10.0"; /// /// Runs an arbitrary dotnet command in the given working directory. diff --git a/dotnet/eng/verify-samples/SampleVerifier.cs b/dotnet/eng/verify-samples/SampleVerifier.cs index 9dc17b1769..ae28aa835f 100644 --- a/dotnet/eng/verify-samples/SampleVerifier.cs +++ b/dotnet/eng/verify-samples/SampleVerifier.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.ComponentModel; using System.Text.Json.Serialization; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -27,11 +28,19 @@ internal sealed class SampleVerifier instructions: """ You are a test output verifier. You will be given: 1. The actual stdout output of a program - 2. A list of expectations about what the output should contain or demonstrate + 2. The stderr output (if any) + 3. A list of expectations about what the output should contain or demonstrate Your job is to determine whether the actual output satisfies each expectation. Be reasonable — the output comes from an LLM so exact wording won't match, but the semantic intent should be clearly satisfied. + + In your response, you MUST: + - Always provide ai_reasoning with a brief overall assessment. + - Always provide exactly one entry in expectation_results for each expectation, + in the same order as the input list. + - For each expectation_results entry, echo the expectation text in the expectation + field and explain your assessment in the detail field, citing evidence from the output. """, name: "OutputVerifier"); } @@ -78,7 +87,7 @@ internal sealed class SampleVerifier } else { - var aiResult = await this.VerifyWithAIAsync(run.Stdout, sample.ExpectedOutputDescription); + var aiResult = await this.VerifyWithAIAsync(run.Stdout, run.Stderr, sample.ExpectedOutputDescription); aiReasoning = aiResult.Reasoning; foreach (var unmet in aiResult.UnmetExpectations) @@ -100,16 +109,28 @@ internal sealed class SampleVerifier } private async Task<(string Reasoning, List UnmetExpectations)> VerifyWithAIAsync( - string actualOutput, + string stdout, + string stderr, string[] expectations) { var expectationList = string.Join("\n", expectations.Select((e, i) => $" {i + 1}. {e}")); + + var stderrSection = string.IsNullOrWhiteSpace(stderr) + ? "" + : $""" + + Stderr output: + --- + {Truncate(stderr, 2000)} + --- + """; + var prompt = $""" Actual program output: --- - {Truncate(actualOutput, 4000)} + {Truncate(stdout, 4000)} --- - + {stderrSection} Expectations to verify: {expectationList} @@ -126,7 +147,9 @@ internal sealed class SampleVerifier return ($"AI verification returned null result. Raw: {response.Text}", ["AI verification returned null result."]); } - var reasoning = result.Reasoning ?? "(no reasoning provided)"; + var reasoning = string.IsNullOrWhiteSpace(result.AIReasoning) + ? "(no reasoning provided)" + : result.AIReasoning; // Collect unmet expectations as individual failures var unmet = new List(); @@ -174,12 +197,14 @@ internal sealed class AIVerificationResponse public bool Pass { get; set; } /// Brief explanation of the overall assessment. - [JsonPropertyName("reasoning")] - public string? Reasoning { get; set; } + [JsonPropertyName("ai_reasoning")] + [Description("Always required. Brief explanation of the overall assessment, covering all expectations.")] + public string AIReasoning { get; set; } = string.Empty; /// Per-expectation results. [JsonPropertyName("expectation_results")] - public List? ExpectationResults { get; set; } + [Description("Always required. One entry per expectation, in the same order as the input list.")] + public List ExpectationResults { get; set; } = []; } /// @@ -190,7 +215,8 @@ internal sealed class ExpectationResult { /// The expectation text that was evaluated. [JsonPropertyName("expectation")] - public string? Expectation { get; set; } + [Description("Echo back the expectation text being evaluated.")] + public string Expectation { get; set; } = string.Empty; /// Whether this expectation was met. [JsonPropertyName("met")] @@ -198,5 +224,6 @@ internal sealed class ExpectationResult /// Detail about how the expectation was or was not met. [JsonPropertyName("detail")] - public string? Detail { get; set; } + [Description("Explain how the expectation was or was not met, citing specific evidence from the output.")] + public string Detail { get; set; } = string.Empty; } diff --git a/dotnet/eng/verify-samples/VerificationOrchestrator.cs b/dotnet/eng/verify-samples/VerificationOrchestrator.cs index 1ce805bc5a..b55efc9c14 100644 --- a/dotnet/eng/verify-samples/VerificationOrchestrator.cs +++ b/dotnet/eng/verify-samples/VerificationOrchestrator.cs @@ -14,19 +14,22 @@ internal sealed class VerificationOrchestrator private readonly LogFileWriter? _logWriter; private readonly string _dotnetRoot; private readonly TimeSpan _timeout; + private readonly bool _buildSamples; public VerificationOrchestrator( SampleVerifier verifier, ConsoleReporter reporter, string dotnetRoot, TimeSpan timeout, - LogFileWriter? logWriter = null) + LogFileWriter? logWriter = null, + bool buildSamples = false) { this._verifier = verifier; this._reporter = reporter; this._logWriter = logWriter; this._dotnetRoot = dotnetRoot; this._timeout = timeout; + this._buildSamples = buildSamples; } /// @@ -136,8 +139,8 @@ internal sealed class VerificationOrchestrator var projectPath = Path.Combine(this._dotnetRoot, sample.ProjectPath); var run = sample.Inputs.Length > 0 - ? await SampleRunner.RunAsync(projectPath, this._timeout, sample.Inputs, sample.InputDelayMs) - : await SampleRunner.RunAsync(projectPath, this._timeout); + ? await SampleRunner.RunAsync(projectPath, this._timeout, sample.Inputs, sample.InputDelayMs, build: this._buildSamples) + : await SampleRunner.RunAsync(projectPath, this._timeout, build: this._buildSamples); log.Add($"[{sample.Name}] Completed ({run.Elapsed.TotalSeconds:F1}s, exit={run.ExitCode})"); this._reporter.WriteLineWithPrefix( diff --git a/dotnet/eng/verify-samples/VerifyOptions.cs b/dotnet/eng/verify-samples/VerifyOptions.cs index 78ba38acf1..95e0af8795 100644 --- a/dotnet/eng/verify-samples/VerifyOptions.cs +++ b/dotnet/eng/verify-samples/VerifyOptions.cs @@ -27,6 +27,12 @@ internal sealed class VerifyOptions /// public string? LogFilePath { get; init; } + /// + /// When true, samples are built as part of dotnet run. + /// When false (the default), --no-build is passed, assuming a prior build step. + /// + public bool BuildSamples { get; init; } + /// /// The filtered list of samples to process. /// @@ -55,6 +61,7 @@ internal sealed class VerifyOptions var logFilePath = ExtractArg(argList, "--log"); var csvFilePath = ExtractArg(argList, "--csv"); var markdownFilePath = ExtractArg(argList, "--md"); + var buildSamples = ExtractFlag(argList, "--build"); int maxParallelism = 8; var parallelArg = ExtractArg(argList, "--parallel"); @@ -105,6 +112,7 @@ internal sealed class VerifyOptions LogFilePath = logFilePath, CsvFilePath = csvFilePath, MarkdownFilePath = markdownFilePath, + BuildSamples = buildSamples, Samples = samples, }; } @@ -128,4 +136,16 @@ internal sealed class VerifyOptions list.RemoveRange(idx, 2); return value; } + + private static bool ExtractFlag(List list, string flag) + { + var idx = list.IndexOf(flag); + if (idx < 0) + { + return false; + } + + list.RemoveAt(idx); + return true; + } } From 8348584ac29f91a2c5e5e3db05166add1bb7b2af Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:43:54 +0100 Subject: [PATCH 05/22] VerifySamples: Filter projects to net10 only (#5184) --- .github/workflows/dotnet-verify-samples.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dotnet-verify-samples.yml b/.github/workflows/dotnet-verify-samples.yml index b1c13a275f..40ee3124b4 100644 --- a/.github/workflows/dotnet-verify-samples.yml +++ b/.github/workflows/dotnet-verify-samples.yml @@ -63,10 +63,19 @@ jobs: tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + - name: Generate filtered solution + shell: pwsh + run: | + ./dotnet/eng/scripts/New-FilteredSolution.ps1 ` + -Solution dotnet/agent-framework-dotnet.slnx ` + -TargetFramework net10.0 ` + -Configuration Debug ` + -OutputPath dotnet/filtered.slnx ` + -Verbose + - name: Build solution - working-directory: dotnet shell: bash - run: dotnet build agent-framework-dotnet.slnx -f net10.0 --warnaserror + run: dotnet build dotnet/filtered.slnx -f net10.0 --warnaserror - name: Run verify-samples id: verify From 1dd828d25502a1d4b4facff8e278da0668b40d28 Mon Sep 17 00:00:00 2001 From: Giles Odigwe <79032838+giles17@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:39:01 -0700 Subject: [PATCH 06/22] CHANGELOG Update with V1.0.0 Release (#5069) --- python/CHANGELOG.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index bd9ff53f18..6f51601910 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0] - 2026-04-02 + +### Added + +- **repo**: Add `PACKAGE_STATUS.md` to track lifecycle status of all Python packages ([#5062](https://github.com/microsoft/agent-framework/pull/5062)) + +### Changed + +- **agent-framework**, **agent-framework-core**, **agent-framework-openai**, **agent-framework-foundry**: [BREAKING] Promote from `1.0.0rc6` to `1.0.0` (Production/Stable) ([#5062](https://github.com/microsoft/agent-framework/pull/5062)) +- **agent-framework-core**, **agent-framework-openai**, **agent-framework-foundry**: [BREAKING] Dependency floors now require released `>=1.0.0,<2` packages, breaking compatibility with older RC installs ([#5062](https://github.com/microsoft/agent-framework/pull/5062)) +- **agent-framework-a2a**, **agent-framework-ag-ui**, **agent-framework-anthropic**, **agent-framework-azure-ai-search**, **agent-framework-azure-cosmos**, **agent-framework-azurefunctions**, **agent-framework-bedrock**, **agent-framework-chatkit**, **agent-framework-claude**, **agent-framework-copilotstudio**, **agent-framework-declarative**, **agent-framework-devui**, **agent-framework-durabletask**, **agent-framework-foundry-local**, **agent-framework-github-copilot**, **agent-framework-lab**, **agent-framework-mem0**, **agent-framework-ollama**, **agent-framework-orchestrations**, **agent-framework-purview**, **agent-framework-redis**: Bump beta versions from `1.0.0b260330` to `1.0.0b260402` ([#5062](https://github.com/microsoft/agent-framework/pull/5062)) +- **docs**: Update install instructions to drop `--pre` flag for released packages ([#5062](https://github.com/microsoft/agent-framework/pull/5062)) + +### Removed + +- **agent-framework-core**: [BREAKING] Remove deprecated `BaseContextProvider` and `BaseHistoryProvider` aliases ([#5062](https://github.com/microsoft/agent-framework/pull/5062)) +- **agent-framework-core**: [BREAKING] Remove deprecated `text` parameter from `Message` constructor ([#5062](https://github.com/microsoft/agent-framework/pull/5062)) + +### Fixed + +- **agent-framework-core**, **agent-framework-openai**, **agent-framework-foundry**, **agent-framework-azurefunctions**, **agent-framework-devui**, **agent-framework-orchestrations**, **agent-framework-azure-ai-search**: Migrate message construction from `Message(text=...)` to `Message(contents=[...])` throughout codebase ([#5062](https://github.com/microsoft/agent-framework/pull/5062)) +- **agent-framework-devui**: Accept legacy payload formats (`text`, `message`, `content`, `input`, `data`) and convert to framework-native `Message(contents=...)` ([#5062](https://github.com/microsoft/agent-framework/pull/5062)) +- **samples**: Fix Foundry samples to use env vars consistently and update install guidance ([#5062](https://github.com/microsoft/agent-framework/pull/5062)) + ## [1.0.0rc6] - 2026-03-30 ### Added @@ -846,7 +870,8 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai** For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/). -[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc6...HEAD +[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0...HEAD +[1.0.0]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc6...python-1.0.0 [1.0.0rc6]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc5...python-1.0.0rc6 [1.0.0rc5]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc4...python-1.0.0rc5 [1.0.0rc4]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc3...python-1.0.0rc4 From 5e8fe0be1f4eda34c820d6fa803bbbcebad10dfb Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Fri, 10 Apr 2026 07:44:59 +0900 Subject: [PATCH 07/22] Python: Stop emitting duplicate reasoning content from OpenAI `response.reasoning_text.done` and `response.reasoning_summary_text.done` events (#5162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix reasoning text done events duplicating streamed delta content (#5157) The OpenAI Responses API sends both reasoning_text.delta (incremental chunks) and reasoning_text.done (full accumulated text) events. The chat client was emitting Content for both, causing ag-ui to append the full done text onto already-accumulated delta text, producing duplicated reasoning output. Stop emitting Content for reasoning_text.done and reasoning_summary_text.done events, matching how output_text.done is already handled (not emitted). The deltas contain all the content; the done event is redundant. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(openai): emit reasoning done content as fallback when no deltas observed (#5157) Address PR review feedback: - Track item_ids that received reasoning deltas via seen_reasoning_delta_item_ids set - Emit content from done events only when no deltas were received for the item_id, preventing silent content loss on stream resumption - Add comment documenting code_interpreter done event asymmetry - Replace redundant ag-ui test with deduplication-focused test - Add integration test for delta+done sequence in OpenAI chat client tests - Add fallback path tests for done events without preceding deltas Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #5157: Python: [Bug]: "type": "response.reasoning_text.delta" and "response.reasoning_text.done" both get exposed as "text_reasoning" * Fix AG-UI reasoning streaming to use proper Start/End pattern (#5157) _emit_text_reasoning now follows the same streaming pattern as _emit_text: - Emits ReasoningStartEvent/ReasoningMessageStartEvent only on the first delta for a given message_id - Emits only ReasoningMessageContentEvent for subsequent deltas - Defers ReasoningMessageEndEvent/ReasoningEndEvent until _close_reasoning_block is called (on content type switch or end-of-run) This produces the correct protocol pattern: ReasoningStartEvent ReasoningMessageStartEvent ReasoningMessageContentEvent(delta1) ReasoningMessageContentEvent(delta2) ReasoningMessageEndEvent ReasoningEndEvent Instead of wrapping every delta in a full Start→End sequence. Backward compatibility is preserved: calling _emit_text_reasoning without a flow argument still produces the full sequence per call. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix import ordering lint error in AG-UI test file (#5157) Move inline import of TextMessageContentEvent to the top-level import block and ensure alphabetical ordering to satisfy ruff I001 rule. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix mypy error: rename loop variable to avoid type conflict with WorkflowEvent The 'event' variable was already typed as WorkflowEvent[Any] from the async for loop at line 590. Reusing it in the _close_reasoning_block loop (which returns list[BaseEvent]) caused an incompatible assignment error. Renamed to 'reasoning_evt' to avoid the conflict. Fixes #5162 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #5157: review comment fixes * narrow test result reporting to explicit pytest JUnit XML * Fix test args * Fix pytest-results-action in merge workflow and remove committed test artifacts Apply the same JUnit XML fix from python-tests.yml to python-merge-tests.yml: add --junitxml=pytest.xml to all test commands and narrow the results action path from ./python/**.xml to ./python/pytest.xml. Also remove accidentally committed pytest.xml and python-coverage.xml and add them to .gitignore. --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/python-merge-tests.yml | 22 ++- .github/workflows/python-tests.yml | 4 +- .gitignore | 2 + .../ag-ui/agent_framework_ag_ui/_agent_run.py | 5 + .../agent_framework_ag_ui/_run_common.py | 106 ++++++++++--- .../agent_framework_ag_ui/_workflow_run.py | 4 + python/packages/ag-ui/tests/ag_ui/test_run.py | 141 +++++++++++++++++- .../_checkpoint_storage.py | 10 +- .../tests/test_cosmos_checkpoint_storage.py | 4 +- .../agent_framework_openai/_chat_client.py | 42 ++++-- .../tests/openai/test_openai_chat_client.py | 124 ++++++++++++++- ...story_provider_conversation_persistence.py | 4 +- .../cosmos_history_provider_sessions.py | 8 +- .../cosmos_workflow_checkpointing.py | 20 +-- .../cosmos_workflow_checkpointing_foundry.py | 5 +- 15 files changed, 412 insertions(+), 89 deletions(-) diff --git a/.github/workflows/python-merge-tests.yml b/.github/workflows/python-merge-tests.yml index 4417165a24..4fc47af595 100644 --- a/.github/workflows/python-merge-tests.yml +++ b/.github/workflows/python-merge-tests.yml @@ -115,12 +115,13 @@ jobs: -m "not integration" --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 + --junitxml=pytest.xml working-directory: ./python - name: Surface failing tests if: always() uses: pmeier/pytest-results-action@v0.7.2 with: - path: ./python/**.xml + path: ./python/pytest.xml summary: true display-options: fEX fail-on-empty: false @@ -163,6 +164,7 @@ jobs: -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 + --junitxml=pytest.xml working-directory: ./python - name: Test OpenAI samples timeout-minutes: 10 @@ -173,7 +175,7 @@ jobs: if: always() uses: pmeier/pytest-results-action@v0.7.2 with: - path: ./python/**.xml + path: ./python/pytest.xml summary: true display-options: fEX fail-on-empty: false @@ -225,6 +227,7 @@ jobs: -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 + --junitxml=pytest.xml working-directory: ./python - name: Test Azure samples timeout-minutes: 10 @@ -235,7 +238,7 @@ jobs: if: always() uses: pmeier/pytest-results-action@v0.7.2 with: - path: ./python/**.xml + path: ./python/pytest.xml summary: true display-options: fEX fail-on-empty: false @@ -285,6 +288,7 @@ jobs: -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 + --junitxml=pytest.xml working-directory: ./python - name: Stop local MCP server if: always() @@ -310,7 +314,7 @@ jobs: if: always() uses: pmeier/pytest-results-action@v0.7.2 with: - path: ./python/**.xml + path: ./python/pytest.xml summary: true display-options: fEX fail-on-empty: false @@ -375,12 +379,13 @@ jobs: -x --timeout=360 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 + --junitxml=pytest.xml working-directory: ./python - name: Surface failing tests if: always() uses: pmeier/pytest-results-action@v0.7.2 with: - path: ./python/**.xml + path: ./python/pytest.xml summary: true display-options: fEX fail-on-empty: false @@ -430,12 +435,13 @@ jobs: -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 + --junitxml=pytest.xml working-directory: ./python - name: Surface failing tests if: always() uses: pmeier/pytest-results-action@v0.7.2 with: - path: ./python/**.xml + path: ./python/pytest.xml summary: true display-options: fEX fail-on-empty: false @@ -489,13 +495,13 @@ jobs: echo "Cosmos DB emulator did not become ready in time." >&2 exit 1 - name: Test with pytest (Cosmos integration) - run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 + run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 --junitxml=pytest.xml working-directory: ./python - name: Surface failing tests if: always() uses: pmeier/pytest-results-action@v0.7.2 with: - path: ./python/**.xml + path: ./python/pytest.xml summary: true display-options: fEX fail-on-empty: false diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 3e12773090..5530be9ffa 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -40,7 +40,7 @@ jobs: UV_CACHE_DIR: /tmp/.uv-cache # Unit tests - name: Run all tests - run: uv run poe test -A + run: uv run poe test -A --junitxml=pytest.xml working-directory: ./python # Surface failing tests @@ -48,7 +48,7 @@ jobs: if: always() uses: pmeier/pytest-results-action@v0.7.2 with: - path: ./python/**.xml + path: ./python/pytest.xml summary: true display-options: fEX fail-on-empty: false diff --git a/.gitignore b/.gitignore index 089abb5395..4994e9e2fe 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,8 @@ htmlcov/ .cache nosetests.xml coverage.xml +pytest.xml +python-coverage.xml *.cover *.py,cover .hypothesis/ diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py b/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py index e9ce610b10..639a3f89b3 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py @@ -46,6 +46,7 @@ from ._orchestration._tooling import collect_server_tools, merge_tools, register from ._run_common import ( FlowState, _build_run_finished_event, # type: ignore + _close_reasoning_block, # type: ignore _emit_content, # type: ignore _extract_resume_payload, # type: ignore _has_only_tool_calls, # type: ignore @@ -1058,6 +1059,10 @@ async def run_agent_stream( } ) + # Close any open reasoning block + for event in _close_reasoning_block(flow): + yield event + # Close any open message if flow.message_id: logger.debug(f"End of run: closing text message message_id={flow.message_id}") diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py b/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py index 155f559a94..81d5fadbbe 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py @@ -128,6 +128,7 @@ class FlowState: interrupts: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] reasoning_messages: list[dict[str, Any]] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType] accumulated_reasoning: dict[str, str] = field(default_factory=dict) # pyright: ignore[reportUnknownVariableType] + reasoning_message_id: str | None = None def get_tool_name(self, call_id: str | None) -> str | None: """Get tool name by call ID.""" @@ -462,12 +463,39 @@ def _emit_mcp_tool_result( return _emit_tool_result_common(content.call_id, raw_output, flow, predictive_handler) +def _close_reasoning_block(flow: FlowState) -> list[BaseEvent]: + """Close an open reasoning block, emitting end events. + + Should be called when the reasoning block is complete -- e.g. when + non-reasoning content arrives or at end of a run. + """ + if not flow.reasoning_message_id: + return [] + message_id = flow.reasoning_message_id + flow.reasoning_message_id = None + return [ + ReasoningMessageEndEvent(message_id=message_id), + ReasoningEndEvent(message_id=message_id), + ] + + def _emit_text_reasoning(content: Content, flow: FlowState | None = None) -> list[BaseEvent]: """Emit AG-UI reasoning events for text_reasoning content. Uses the protocol-defined reasoning event types so that AG-UI consumers such as CopilotKit can render reasoning natively. + When *flow* is provided the function follows the streaming pattern: it + emits ``ReasoningStartEvent`` / ``ReasoningMessageStartEvent`` only on + the first delta for a given ``message_id`` and just + ``ReasoningMessageContentEvent`` for subsequent deltas. The matching + ``ReasoningMessageEndEvent`` / ``ReasoningEndEvent`` are deferred until + ``_close_reasoning_block`` is called (e.g. when non-reasoning content + arrives or at end-of-run). + + Without *flow* (backward-compat) the full Start→Content→End sequence is + emitted for every call. + Only ``content.text`` is used for the visible reasoning message. If ``content.protected_data`` is present it is emitted as a ``ReasoningEncryptedValueEvent`` so that consumers can persist encrypted @@ -483,26 +511,49 @@ def _emit_text_reasoning(content: Content, flow: FlowState | None = None) -> lis message_id = content.id or generate_event_id() - events: list[BaseEvent] = [ - ReasoningStartEvent(message_id=message_id), - ReasoningMessageStartEvent(message_id=message_id, role="assistant"), - ] + events: list[BaseEvent] = [] - if text: - events.append(ReasoningMessageContentEvent(message_id=message_id, delta=text)) + if flow is not None: + # Streaming mode: track open reasoning block in flow state. + if flow.reasoning_message_id != message_id: + # Close any previously open reasoning block (different message_id). + events.extend(_close_reasoning_block(flow)) + # Open new reasoning block. + events.append(ReasoningStartEvent(message_id=message_id)) + events.append(ReasoningMessageStartEvent(message_id=message_id, role="assistant")) + flow.reasoning_message_id = message_id - events.append(ReasoningMessageEndEvent(message_id=message_id)) + if text: + events.append(ReasoningMessageContentEvent(message_id=message_id, delta=text)) - if content.protected_data is not None: - events.append( - ReasoningEncryptedValueEvent( - subtype="message", - entity_id=message_id, - encrypted_value=content.protected_data, + if content.protected_data is not None: + events.append( + ReasoningEncryptedValueEvent( + subtype="message", + entity_id=message_id, + encrypted_value=content.protected_data, + ) ) - ) + else: + # No flow -- backward-compatible full sequence per call. + events.append(ReasoningStartEvent(message_id=message_id)) + events.append(ReasoningMessageStartEvent(message_id=message_id, role="assistant")) - events.append(ReasoningEndEvent(message_id=message_id)) + if text: + events.append(ReasoningMessageContentEvent(message_id=message_id, delta=text)) + + events.append(ReasoningMessageEndEvent(message_id=message_id)) + + if content.protected_data is not None: + events.append( + ReasoningEncryptedValueEvent( + subtype="message", + entity_id=message_id, + encrypted_value=content.protected_data, + ) + ) + + events.append(ReasoningEndEvent(message_id=message_id)) # Persist reasoning into flow state for MESSAGES_SNAPSHOT. # Accumulate reasoning text per message_id, similar to flow.accumulated_text, @@ -546,23 +597,30 @@ def _emit_content( ) -> list[BaseEvent]: """Emit appropriate events for any content type.""" content_type = getattr(content, "type", None) + + # Close open reasoning block when switching to non-reasoning content. + if content_type != "text_reasoning": + events = _close_reasoning_block(flow) + else: + events = [] + if content_type == "text": - return _emit_text(content, flow, skip_text) + return events + _emit_text(content, flow, skip_text) if content_type == "function_call": - return _emit_tool_call(content, flow, predictive_handler) + return events + _emit_tool_call(content, flow, predictive_handler) if content_type == "function_result": - return _emit_tool_result(content, flow, predictive_handler) + return events + _emit_tool_result(content, flow, predictive_handler) if content_type == "function_approval_request": - return _emit_approval_request(content, flow, predictive_handler, require_confirmation) + return events + _emit_approval_request(content, flow, predictive_handler, require_confirmation) if content_type == "usage": - return _emit_usage(content) + return events + _emit_usage(content) if content_type == "oauth_consent_request": - return _emit_oauth_consent(content) + return events + _emit_oauth_consent(content) if content_type == "mcp_server_tool_call": - return _emit_mcp_tool_call(content, flow) + return events + _emit_mcp_tool_call(content, flow) if content_type == "mcp_server_tool_result": - return _emit_mcp_tool_result(content, flow, predictive_handler) + return events + _emit_mcp_tool_result(content, flow, predictive_handler) if content_type == "text_reasoning": return _emit_text_reasoning(content, flow) logger.debug("Skipping unsupported content type in AG-UI emitter: %s", content_type) - return [] + return events diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py b/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py index a75d29abc4..d34cb7db61 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py @@ -29,6 +29,7 @@ from ._message_adapters import normalize_agui_input_messages from ._run_common import ( FlowState, _build_run_finished_event, + _close_reasoning_block, _emit_content, _extract_resume_payload, _normalize_resume_interrupts, @@ -729,6 +730,9 @@ async def run_workflow_stream( run_error_emitted = True terminal_emitted = True + for reasoning_evt in _close_reasoning_block(flow): + yield reasoning_evt + for end_event in _drain_open_message(): yield end_event diff --git a/python/packages/ag-ui/tests/ag_ui/test_run.py b/python/packages/ag-ui/tests/ag_ui/test_run.py index 0e5c329ce9..18b0d0d7e4 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_run.py +++ b/python/packages/ag-ui/tests/ag_ui/test_run.py @@ -11,6 +11,7 @@ from ag_ui.core import ( ReasoningMessageEndEvent, ReasoningMessageStartEvent, ReasoningStartEvent, + TextMessageContentEvent, TextMessageEndEvent, TextMessageStartEvent, ToolCallArgsEvent, @@ -29,6 +30,7 @@ from agent_framework_ag_ui._agent_run import ( from agent_framework_ag_ui._run_common import ( FlowState, _build_run_finished_event, + _close_reasoning_block, _emit_approval_request, _emit_content, _emit_mcp_tool_call, @@ -1344,8 +1346,11 @@ class TestEmitContentMcpRouting: events = _emit_content(content, flow) - assert len(events) == 5 + # Streaming pattern: Start + MessageStart + Content (no End events yet) + assert len(events) == 3 assert isinstance(events[0], ReasoningStartEvent) + assert isinstance(events[1], ReasoningMessageStartEvent) + assert isinstance(events[2], ReasoningMessageContentEvent) class TestReasoningInSnapshot: @@ -1501,3 +1506,137 @@ class TestReasoningInSnapshot: assert len(flow.reasoning_messages) == 1 assert flow.reasoning_messages[0]["content"] == "part1 part2" assert flow.reasoning_messages[0]["encryptedValue"] == "encrypted-payload" + + def test_reasoning_done_after_deltas_does_not_duplicate(self): + """A done-style content arriving after deltas does not duplicate accumulated text. + + The upstream client should skip done events when deltas preceded them, + but if one leaks through, the accumulator must not double-append. + This test verifies that only the delta-produced text is stored. + """ + flow = FlowState() + msg_id = "reason_dedup" + + delta1 = Content.from_text_reasoning(id=msg_id, text="Hello ") + delta2 = Content.from_text_reasoning(id=msg_id, text="world") + + _emit_text_reasoning(delta1, flow) + _emit_text_reasoning(delta2, flow) + + # Accumulated text should equal the concatenation of deltas only + assert len(flow.reasoning_messages) == 1 + assert flow.reasoning_messages[0]["content"] == "Hello world" + assert flow.reasoning_messages[0]["id"] == msg_id + + def test_reasoning_deltas_emit_one_content_event_each(self): + """Each reasoning delta emits exactly one ReasoningMessageContentEvent + within a single Start/End sequence (streaming pattern).""" + flow = FlowState() + msg_id = "reason_evt" + + delta1 = Content.from_text_reasoning(id=msg_id, text="Think ") + delta2 = Content.from_text_reasoning(id=msg_id, text="hard") + + events1 = _emit_text_reasoning(delta1, flow) + events2 = _emit_text_reasoning(delta2, flow) + close_events = _close_reasoning_block(flow) + + all_events = events1 + events2 + close_events + content_events = [e for e in all_events if isinstance(e, ReasoningMessageContentEvent)] + + assert len(content_events) == 2 + assert content_events[0].delta == "Think " + assert content_events[1].delta == "hard" + + # Streaming pattern: one Start/End sequence wrapping both content events + start_events = [e for e in all_events if isinstance(e, ReasoningStartEvent)] + end_events = [e for e in all_events if isinstance(e, ReasoningEndEvent)] + msg_start_events = [e for e in all_events if isinstance(e, ReasoningMessageStartEvent)] + msg_end_events = [e for e in all_events if isinstance(e, ReasoningMessageEndEvent)] + assert len(start_events) == 1 + assert len(end_events) == 1 + assert len(msg_start_events) == 1 + assert len(msg_end_events) == 1 + + def test_reasoning_streaming_event_order(self): + """Streaming reasoning emits Start once, then Content per delta, then End on close.""" + flow = FlowState() + msg_id = "reason_order" + + d1 = Content.from_text_reasoning(id=msg_id, text="A ") + d2 = Content.from_text_reasoning(id=msg_id, text="B ") + d3 = Content.from_text_reasoning(id=msg_id, text="C") + + events = [] + events.extend(_emit_text_reasoning(d1, flow)) + events.extend(_emit_text_reasoning(d2, flow)) + events.extend(_emit_text_reasoning(d3, flow)) + events.extend(_close_reasoning_block(flow)) + + assert isinstance(events[0], ReasoningStartEvent) + assert isinstance(events[1], ReasoningMessageStartEvent) + assert isinstance(events[2], ReasoningMessageContentEvent) + assert events[2].delta == "A " + assert isinstance(events[3], ReasoningMessageContentEvent) + assert events[3].delta == "B " + assert isinstance(events[4], ReasoningMessageContentEvent) + assert events[4].delta == "C" + assert isinstance(events[5], ReasoningMessageEndEvent) + assert isinstance(events[6], ReasoningEndEvent) + assert len(events) == 7 + + def test_close_reasoning_block_noop_when_not_open(self): + """_close_reasoning_block returns empty list when no reasoning block is open.""" + flow = FlowState() + assert _close_reasoning_block(flow) == [] + + def test_close_reasoning_block_resets_state(self): + """_close_reasoning_block clears reasoning_message_id.""" + flow = FlowState() + _emit_text_reasoning(Content.from_text_reasoning(id="r1", text="x"), flow) + assert flow.reasoning_message_id == "r1" + + _close_reasoning_block(flow) + assert flow.reasoning_message_id is None + + def test_emit_content_closes_reasoning_on_text(self): + """Switching from reasoning to text content auto-closes reasoning block.""" + flow = FlowState() + reasoning = Content.from_text_reasoning(id="r1", text="thinking") + text = Content.from_text("answer") + + r_events = _emit_content(reasoning, flow) + t_events = _emit_content(text, flow) + + # reasoning events: Start + MsgStart + Content + assert isinstance(r_events[0], ReasoningStartEvent) + # text events should start with reasoning End events + assert isinstance(t_events[0], ReasoningMessageEndEvent) + assert isinstance(t_events[1], ReasoningEndEvent) + # then text start + + assert isinstance(t_events[2], TextMessageStartEvent) + assert isinstance(t_events[3], TextMessageContentEvent) + + def test_reasoning_distinct_ids_close_previous_block(self): + """Emitting reasoning with a new message_id auto-closes the previous block.""" + flow = FlowState() + c1 = Content.from_text_reasoning(id="block1", text="first") + c2 = Content.from_text_reasoning(id="block2", text="second") + + events1 = _emit_text_reasoning(c1, flow) + events2 = _emit_text_reasoning(c2, flow) + close = _close_reasoning_block(flow) + + # events1: Start(block1) + MsgStart(block1) + Content(block1) + assert events1[0].message_id == "block1" + # events2: MsgEnd(block1) + End(block1) + Start(block2) + MsgStart(block2) + Content(block2) + assert isinstance(events2[0], ReasoningMessageEndEvent) + assert events2[0].message_id == "block1" + assert isinstance(events2[1], ReasoningEndEvent) + assert events2[1].message_id == "block1" + assert isinstance(events2[2], ReasoningStartEvent) + assert events2[2].message_id == "block2" + # close: MsgEnd(block2) + End(block2) + assert isinstance(close[0], ReasoningMessageEndEvent) + assert close[0].message_id == "block2" diff --git a/python/packages/azure-cosmos/agent_framework_azure_cosmos/_checkpoint_storage.py b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_checkpoint_storage.py index 4544311fd9..1b6257f203 100644 --- a/python/packages/azure-cosmos/agent_framework_azure_cosmos/_checkpoint_storage.py +++ b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_checkpoint_storage.py @@ -315,10 +315,7 @@ class CosmosCheckpointStorage: """ await self._ensure_container_proxy() - query = ( - "SELECT * FROM c WHERE c.workflow_name = @workflow_name " - "ORDER BY c.timestamp DESC OFFSET 0 LIMIT 1" - ) + query = "SELECT * FROM c WHERE c.workflow_name = @workflow_name ORDER BY c.timestamp DESC OFFSET 0 LIMIT 1" parameters: list[dict[str, object]] = [ {"name": "@workflow_name", "value": workflow_name}, ] @@ -351,10 +348,7 @@ class CosmosCheckpointStorage: """ await self._ensure_container_proxy() - query = ( - "SELECT c.checkpoint_id FROM c WHERE c.workflow_name = @workflow_name " - "ORDER BY c.timestamp ASC" - ) + query = "SELECT c.checkpoint_id FROM c WHERE c.workflow_name = @workflow_name ORDER BY c.timestamp ASC" parameters: list[dict[str, object]] = [ {"name": "@workflow_name", "value": workflow_name}, ] diff --git a/python/packages/azure-cosmos/tests/test_cosmos_checkpoint_storage.py b/python/packages/azure-cosmos/tests/test_cosmos_checkpoint_storage.py index 5e183c3223..52155d0e21 100644 --- a/python/packages/azure-cosmos/tests/test_cosmos_checkpoint_storage.py +++ b/python/packages/azure-cosmos/tests/test_cosmos_checkpoint_storage.py @@ -402,9 +402,7 @@ async def test_list_checkpoint_ids_empty_returns_empty(mock_container: MagicMock # --- Tests for close and context manager --- -async def test_close_closes_owned_client( - monkeypatch: pytest.MonkeyPatch, mock_cosmos_client: MagicMock -) -> None: +async def test_close_closes_owned_client(monkeypatch: pytest.MonkeyPatch, mock_cosmos_client: MagicMock) -> None: mock_factory = MagicMock(return_value=mock_cosmos_client) monkeypatch.setattr(checkpoint_storage_module, "CosmosClient", mock_factory) diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index f5c0eea03e..ecd17c4b5e 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -512,6 +512,7 @@ class RawOpenAIChatClient( # type: ignore[misc] if stream: function_call_ids: dict[int, tuple[str, str]] = {} + seen_reasoning_delta_item_ids: set[str] = set() validated_options: dict[str, Any] | None = None async def _stream() -> AsyncIterable[ChatResponseUpdate]: @@ -530,6 +531,7 @@ class RawOpenAIChatClient( # type: ignore[misc] chunk, options=validated_options, function_call_ids=function_call_ids, + seen_reasoning_delta_item_ids=seen_reasoning_delta_item_ids, ) except Exception as ex: self._handle_request_error(ex) @@ -1930,6 +1932,7 @@ class RawOpenAIChatClient( # type: ignore[misc] event: OpenAIResponseStreamEvent, options: dict[str, Any], function_call_ids: dict[int, tuple[str, str]], + seen_reasoning_delta_item_ids: set[str] | None = None, ) -> ChatResponseUpdate: """Parse an OpenAI Responses API streaming event into a ChatResponseUpdate.""" metadata: dict[str, Any] = {} @@ -2008,6 +2011,8 @@ class RawOpenAIChatClient( # type: ignore[misc] contents.append(Content.from_text(text=event.delta, raw_representation=event)) metadata.update(self._get_metadata_from_response(event)) case "response.reasoning_text.delta": + if seen_reasoning_delta_item_ids is not None: + seen_reasoning_delta_item_ids.add(event.item_id) contents.append( Content.from_text_reasoning( id=event.item_id, @@ -2017,15 +2022,21 @@ class RawOpenAIChatClient( # type: ignore[misc] ) metadata.update(self._get_metadata_from_response(event)) case "response.reasoning_text.done": - contents.append( - Content.from_text_reasoning( - id=event.item_id, - text=event.text, - raw_representation=event, + # Done event carries the full accumulated text. Emit it only as a + # fallback when no delta was already received for this item_id, to + # avoid duplicating content in downstream accumulators (e.g. ag-ui). + if seen_reasoning_delta_item_ids is None or event.item_id not in seen_reasoning_delta_item_ids: + contents.append( + Content.from_text_reasoning( + id=event.item_id, + text=event.text, + raw_representation=event, + ) ) - ) metadata.update(self._get_metadata_from_response(event)) case "response.reasoning_summary_text.delta": + if seen_reasoning_delta_item_ids is not None: + seen_reasoning_delta_item_ids.add(event.item_id) contents.append( Content.from_text_reasoning( id=event.item_id, @@ -2035,13 +2046,17 @@ class RawOpenAIChatClient( # type: ignore[misc] ) metadata.update(self._get_metadata_from_response(event)) case "response.reasoning_summary_text.done": - contents.append( - Content.from_text_reasoning( - id=event.item_id, - text=event.text, - raw_representation=event, + # Done event carries the full accumulated text. Emit it only as a + # fallback when no delta was already received for this item_id, to + # avoid duplicating content in downstream accumulators (e.g. ag-ui). + if seen_reasoning_delta_item_ids is None or event.item_id not in seen_reasoning_delta_item_ids: + contents.append( + Content.from_text_reasoning( + id=event.item_id, + text=event.text, + raw_representation=event, + ) ) - ) metadata.update(self._get_metadata_from_response(event)) case "response.code_interpreter_call_code.delta": call_id = getattr(event, "call_id", None) or getattr(event, "id", None) or event.item_id @@ -2065,6 +2080,9 @@ class RawOpenAIChatClient( # type: ignore[misc] ) ) metadata.update(self._get_metadata_from_response(event)) + # NOTE: Unlike reasoning done events, code_interpreter done events always + # emit content because downstream consumers do not accumulate + # code_interpreter deltas the same way. case "response.code_interpreter_call_code.done": call_id = getattr(event, "call_id", None) or getattr(event, "id", None) or event.item_id ci_additional_properties = { diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index fd55321238..1e18f85273 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -2808,11 +2808,12 @@ def test_streaming_reasoning_text_delta_event() -> None: mock_metadata.assert_called_once_with(event) -def test_streaming_reasoning_text_done_event() -> None: - """Test reasoning text done event creates TextReasoningContent with complete text.""" +def test_streaming_reasoning_text_done_event_skipped_after_deltas() -> None: + """Test reasoning text done event does not emit content when deltas were already received.""" client = OpenAIChatClient(model="test-model", api_key="test-key") chat_options = ChatOptions() function_call_ids: dict[int, tuple[str, str]] = {} + seen_reasoning_delta_item_ids: set[str] = {"reasoning_456"} event = ResponseReasoningTextDoneEvent( type="response.reasoning_text.done", @@ -2824,12 +2825,40 @@ def test_streaming_reasoning_text_done_event() -> None: ) with patch.object(client, "_get_metadata_from_response", return_value={"test": "data"}) as mock_metadata: - response = client._parse_chunk_from_openai(event, chat_options, function_call_ids) # type: ignore + response = client._parse_chunk_from_openai( + event, chat_options, function_call_ids, seen_reasoning_delta_item_ids + ) # type: ignore + + assert len(response.contents) == 0 + mock_metadata.assert_called_once_with(event) + assert response.additional_properties == {"test": "data"} + + +def test_streaming_reasoning_text_done_event_fallback_without_deltas() -> None: + """Test reasoning text done event emits content when no deltas were received for this item_id.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + chat_options = ChatOptions() + function_call_ids: dict[int, tuple[str, str]] = {} + seen_reasoning_delta_item_ids: set[str] = set() + + event = ResponseReasoningTextDoneEvent( + type="response.reasoning_text.done", + content_index=0, + item_id="reasoning_456", + output_index=0, + sequence_number=2, + text="complete reasoning", + ) + + with patch.object(client, "_get_metadata_from_response", return_value={"test": "data"}) as mock_metadata: + response = client._parse_chunk_from_openai( + event, chat_options, function_call_ids, seen_reasoning_delta_item_ids + ) # type: ignore assert len(response.contents) == 1 assert response.contents[0].type == "text_reasoning" + assert response.contents[0].id == "reasoning_456" assert response.contents[0].text == "complete reasoning" - assert response.contents[0].raw_representation == event mock_metadata.assert_called_once_with(event) assert response.additional_properties == {"test": "data"} @@ -2859,11 +2888,12 @@ def test_streaming_reasoning_summary_text_delta_event() -> None: mock_metadata.assert_called_once_with(event) -def test_streaming_reasoning_summary_text_done_event() -> None: - """Test reasoning summary text done event creates TextReasoningContent with complete text.""" +def test_streaming_reasoning_summary_text_done_event_skipped_after_deltas() -> None: + """Test reasoning summary text done event does not emit content when deltas were already received.""" client = OpenAIChatClient(model="test-model", api_key="test-key") chat_options = ChatOptions() function_call_ids: dict[int, tuple[str, str]] = {} + seen_reasoning_delta_item_ids: set[str] = {"summary_012"} event = ResponseReasoningSummaryTextDoneEvent( type="response.reasoning_summary_text.done", @@ -2875,16 +2905,94 @@ def test_streaming_reasoning_summary_text_done_event() -> None: ) with patch.object(client, "_get_metadata_from_response", return_value={"custom": "meta"}) as mock_metadata: - response = client._parse_chunk_from_openai(event, chat_options, function_call_ids) # type: ignore + response = client._parse_chunk_from_openai( + event, chat_options, function_call_ids, seen_reasoning_delta_item_ids + ) # type: ignore + + assert len(response.contents) == 0 + mock_metadata.assert_called_once_with(event) + assert response.additional_properties == {"custom": "meta"} + + +def test_streaming_reasoning_summary_text_done_event_fallback_without_deltas() -> None: + """Test reasoning summary text done event emits content when no deltas were received for this item_id.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + chat_options = ChatOptions() + function_call_ids: dict[int, tuple[str, str]] = {} + seen_reasoning_delta_item_ids: set[str] = set() + + event = ResponseReasoningSummaryTextDoneEvent( + type="response.reasoning_summary_text.done", + item_id="summary_012", + output_index=0, + sequence_number=4, + summary_index=0, + text="complete summary", + ) + + with patch.object(client, "_get_metadata_from_response", return_value={"custom": "meta"}) as mock_metadata: + response = client._parse_chunk_from_openai( + event, chat_options, function_call_ids, seen_reasoning_delta_item_ids + ) # type: ignore assert len(response.contents) == 1 assert response.contents[0].type == "text_reasoning" + assert response.contents[0].id == "summary_012" assert response.contents[0].text == "complete summary" - assert response.contents[0].raw_representation == event mock_metadata.assert_called_once_with(event) assert response.additional_properties == {"custom": "meta"} +def test_streaming_reasoning_deltas_then_done_no_duplication() -> None: + """Sending delta events followed by a done event produces content only from deltas.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + chat_options = ChatOptions() + function_call_ids: dict[int, tuple[str, str]] = {} + seen_reasoning_delta_item_ids: set[str] = set() + item_id = "reasoning_seq" + + delta1 = ResponseReasoningTextDeltaEvent( + type="response.reasoning_text.delta", + content_index=0, + item_id=item_id, + output_index=0, + sequence_number=1, + delta="Hello ", + ) + delta2 = ResponseReasoningTextDeltaEvent( + type="response.reasoning_text.delta", + content_index=0, + item_id=item_id, + output_index=0, + sequence_number=2, + delta="world", + ) + done = ResponseReasoningTextDoneEvent( + type="response.reasoning_text.done", + content_index=0, + item_id=item_id, + output_index=0, + sequence_number=3, + text="Hello world", + ) + + all_contents = [] + with patch.object(client, "_get_metadata_from_response", return_value={}): + for event in [delta1, delta2, done]: + response = client._parse_chunk_from_openai( + event, + chat_options, + function_call_ids, + seen_reasoning_delta_item_ids, # type: ignore + ) + all_contents.extend(response.contents) + + assert len(all_contents) == 2 + assert all_contents[0].text == "Hello " + assert all_contents[1].text == "world" + assert "".join(c.text for c in all_contents) == "Hello world" + + def test_streaming_reasoning_events_preserve_metadata() -> None: """Test that reasoning events preserve metadata like regular text events.""" client = OpenAIChatClient(model="test-model", api_key="test-key") diff --git a/python/samples/02-agents/conversations/cosmos_history_provider_conversation_persistence.py b/python/samples/02-agents/conversations/cosmos_history_provider_conversation_persistence.py index ef2b444d28..548f09a92a 100644 --- a/python/samples/02-agents/conversations/cosmos_history_provider_conversation_persistence.py +++ b/python/samples/02-agents/conversations/cosmos_history_provider_conversation_persistence.py @@ -82,9 +82,7 @@ async def main() -> None: ): session = agent.create_session() - response1 = await agent.run( - "My name is Ada. I'm building a distributed database in Rust.", session=session - ) + response1 = await agent.run("My name is Ada. I'm building a distributed database in Rust.", session=session) print("User: My name is Ada. I'm building a distributed database in Rust.") print(f"Assistant: {response1.text}\n") diff --git a/python/samples/02-agents/conversations/cosmos_history_provider_sessions.py b/python/samples/02-agents/conversations/cosmos_history_provider_sessions.py index 2d1861e503..31e8eef16c 100644 --- a/python/samples/02-agents/conversations/cosmos_history_provider_sessions.py +++ b/python/samples/02-agents/conversations/cosmos_history_provider_sessions.py @@ -82,9 +82,7 @@ async def main() -> None: alice_session = agent.create_session(session_id="tenant-alice-session-1") - response = await agent.run( - "Hi! I'm planning a trip to Italy. I love Renaissance art.", session=alice_session - ) + response = await agent.run("Hi! I'm planning a trip to Italy. I love Renaissance art.", session=alice_session) print("Alice: I'm planning a trip to Italy. I love Renaissance art.") print(f"Assistant: {response.text}\n") @@ -97,9 +95,7 @@ async def main() -> None: bob_session = agent.create_session(session_id="tenant-bob-session-1") - response = await agent.run( - "Hey! I'm learning to cook Thai food. I just made pad thai.", session=bob_session - ) + response = await agent.run("Hey! I'm learning to cook Thai food. I just made pad thai.", session=bob_session) print("Bob: I'm learning to cook Thai food. I just made pad thai.") print(f"Assistant: {response.text}\n") diff --git a/python/samples/03-workflows/checkpoint/cosmos_workflow_checkpointing.py b/python/samples/03-workflows/checkpoint/cosmos_workflow_checkpointing.py index 4726742ffc..fd4608db03 100644 --- a/python/samples/03-workflows/checkpoint/cosmos_workflow_checkpointing.py +++ b/python/samples/03-workflows/checkpoint/cosmos_workflow_checkpointing.py @@ -111,10 +111,7 @@ async def main() -> None: cosmos_key = os.getenv("AZURE_COSMOS_KEY") if not cosmos_endpoint or not cosmos_database_name or not cosmos_container_name: - print( - "Please set AZURE_COSMOS_ENDPOINT, AZURE_COSMOS_DATABASE_NAME, " - "and AZURE_COSMOS_CONTAINER_NAME." - ) + print("Please set AZURE_COSMOS_ENDPOINT, AZURE_COSMOS_DATABASE_NAME, and AZURE_COSMOS_CONTAINER_NAME.") return # Authentication: supports both managed identity/RBAC and key-based auth. @@ -131,12 +128,15 @@ async def main() -> None: else: from azure.identity.aio import DefaultAzureCredential - async with DefaultAzureCredential() as credential, CosmosCheckpointStorage( - endpoint=cosmos_endpoint, - credential=credential, - database_name=cosmos_database_name, - container_name=cosmos_container_name, - ) as checkpoint_storage: + async with ( + DefaultAzureCredential() as credential, + CosmosCheckpointStorage( + endpoint=cosmos_endpoint, + credential=credential, + database_name=cosmos_database_name, + container_name=cosmos_container_name, + ) as checkpoint_storage, + ): await _run_workflow(checkpoint_storage) diff --git a/python/samples/03-workflows/checkpoint/cosmos_workflow_checkpointing_foundry.py b/python/samples/03-workflows/checkpoint/cosmos_workflow_checkpointing_foundry.py index 49c3e779f9..7d4f6ad17f 100644 --- a/python/samples/03-workflows/checkpoint/cosmos_workflow_checkpointing_foundry.py +++ b/python/samples/03-workflows/checkpoint/cosmos_workflow_checkpointing_foundry.py @@ -57,10 +57,7 @@ async def main() -> None: return if not cosmos_endpoint or not cosmos_database_name or not cosmos_container_name: - print( - "Please set AZURE_COSMOS_ENDPOINT, AZURE_COSMOS_DATABASE_NAME, " - "and AZURE_COSMOS_CONTAINER_NAME." - ) + print("Please set AZURE_COSMOS_ENDPOINT, AZURE_COSMOS_DATABASE_NAME, and AZURE_COSMOS_CONTAINER_NAME.") return # Use a single AzureCliCredential for both Cosmos and Foundry, From 4dbe696e0e87a7c4b24fdb56507707923a46d61d Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Fri, 10 Apr 2026 09:04:17 +0900 Subject: [PATCH 08/22] Python: Restrict persisted checkpoint deserialization by default (#4941) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Harden Python checkpoint persistence defaults Add RestrictedUnpickler to _checkpoint_encoding.py that limits which types may be instantiated during pickle deserialization. By default FileCheckpointStorage now uses the restricted unpickler, allowing only: - Built-in Python value types (primitives, datetime, uuid, decimal, collections, etc.) - All agent_framework.* internal types - Additional types specified via the new allowed_checkpoint_types parameter on FileCheckpointStorage This narrows the default type surface area for persisted checkpoints while keeping framework-owned scenarios working without extra configuration. Developers can extend the allowed set by passing "module:qualname" strings to allowed_checkpoint_types. The decode_checkpoint_value function retains backward-compatible unrestricted behavior when called without the new allowed_types kwarg. Fixes #4894 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: resolve mypy no-any-return error in checkpoint encoding Add explicit type annotation for super().find_class() return value to satisfy mypy's no-any-return check. Fixes #4894 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Simplify find_class return in _RestrictedUnpickler (#4894) Remove unnecessary intermediate variable and apply # noqa: S301 # nosec directly on the super().find_class() call, matching the established pattern used on the pickle.loads() call in the same file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #4894: Python: Harden Python checkpoint persistence defaults * Restore # noqa: S301 on line 102 of _checkpoint_encoding.py (#4894) The review feedback correctly identified that removing the # noqa: S301 suppression from the find_class return statement would cause a ruff S301 lint failure, since the project enables bandit ("S") rules. This restores consistency with lines 82 and 246 in the same file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #4894: Python: Harden Python checkpoint persistence defaults * Address PR review comments on checkpoint encoding (#4894) - Move module docstring to proper position after __future__ import - Fix find_class return type annotation to type[Any] - Add missing # noqa: S301 pragma on find_class return - Improve error message to reference both allowed_types param and FileCheckpointStorage.allowed_checkpoint_types - Add -> None return annotation to FileCheckpointStorage.__init__ - Replace tempfile.mktemp with TemporaryDirectory in test - Replace contextlib.suppress with pytest.raises for precise assertion - Remove unused contextlib import Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR #4941 review comments: fix docstring position and return type - Move module docstring before 'from __future__' import so it populates __doc__ (comment #4) - Change find_class return annotation from type[Any] to type to avoid misleading callers about non-type returns like copyreg._reconstructor (comment #2) Comments #1, #3, #5, #6, #7, #8 were already addressed in the current code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #4894: review comment fixes * fix: use pickle.UnpicklingError in RestrictedUnpickler and improve docstring (#4894) - Change _RestrictedUnpickler.find_class to raise pickle.UnpicklingError instead of WorkflowCheckpointException, since it is pickle-level concern that gets wrapped by the caller in _base64_to_unpickle. - Remove now-unnecessary WorkflowCheckpointException re-raise in _base64_to_unpickle (pickle.UnpicklingError is caught by the generic except Exception handler and wrapped). - Expand decode_checkpoint_value docstring to show a concrete example of the module:qualname format with a user-defined class. - Add regression test verifying find_class raises pickle.UnpicklingError. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address PR #4941 review comments for checkpoint encoding - Comment 1 (line 103): Already resolved in prior commit — _RestrictedUnpickler now raises pickle.UnpicklingError instead of WorkflowCheckpointException. - Comment 2 (line 140): Add concrete usage examples to decode_checkpoint_value docstring showing both direct allowed_types usage and FileCheckpointStorage allowed_checkpoint_types usage. Rename 'SafeState' to 'MyState' across all docstrings for consistency, making it clear this is a user-defined class name. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: replace deprecated 'builtin' repo with pre-commit-hooks in pre-commit config pre-commit 4.x no longer supports 'repo: builtin'. Merge those hooks into the existing pre-commit-hooks repo entry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * style: apply pyupgrade formatting to docstring example Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: resolve pre-commit hook paths for monorepo git root The poe-check and bandit hooks referenced paths relative to python/ but pre-commit runs hooks from the git root (monorepo root). Fix poe-check entry to cd into python/ first, and update bandit config path to python/pyproject.toml. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix pre-commit config paths for prek --cd python execution Revert bandit config path from 'python/pyproject.toml' to 'pyproject.toml' and poe-check entry from explicit 'cd python' wrapper to direct invocation, since prek --cd python already sets the working directory to python/. Also apply ruff formatting fixes to cosmos checkpoint storage files. Fixes #4894 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: add builtins:getattr to checkpoint deserialization allowlist Pickle uses builtins:getattr to reconstruct enum members (e.g., WorkflowMessage.type which is a MessageType enum). Without it in the allowlist, checkpoint roundtrip tests fail with WorkflowCheckpointException. Fixes #4894 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #4894: review comment fixes --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/.pre-commit-config.yaml | 6 +- .../agent_framework/_workflows/_checkpoint.py | 39 +- .../_workflows/_checkpoint_encoding.py | 164 +- .../core/tests/workflow/test_checkpoint.py | 18 +- .../test_checkpoint_unrestricted_pickle.py | 218 + .../test_request_info_event_rehydrate.py | 36 +- python/pytest.xml | 1 + python/python-coverage.xml | 28040 ++++++++++++++++ 8 files changed, 28479 insertions(+), 43 deletions(-) create mode 100644 python/packages/core/tests/workflow/test_checkpoint_unrestricted_pickle.py create mode 100644 python/pytest.xml create mode 100644 python/python-coverage.xml diff --git a/python/.pre-commit-config.yaml b/python/.pre-commit-config.yaml index bbb2683c5c..adf7e6e5b3 100644 --- a/python/.pre-commit-config.yaml +++ b/python/.pre-commit-config.yaml @@ -1,7 +1,8 @@ fail_fast: true exclude: ^scripts/ repos: - - repo: builtin + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 hooks: - id: check-toml name: Check TOML files @@ -34,9 +35,6 @@ repos: - id: no-commit-to-branch name: Protect main branch args: [--branch, main] - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v6.0.0 - hooks: - id: check-ast name: Check Valid Python Samples types: ["python"] diff --git a/python/packages/core/agent_framework/_workflows/_checkpoint.py b/python/packages/core/agent_framework/_workflows/_checkpoint.py index b442f445f8..f9a940a7db 100644 --- a/python/packages/core/agent_framework/_workflows/_checkpoint.py +++ b/python/packages/core/agent_framework/_workflows/_checkpoint.py @@ -244,14 +244,39 @@ class FileCheckpointStorage: is serialized using pickle and embedded as base64-encoded strings within the JSON. This allows for human-readable checkpoint files while preserving the ability to store complex Python objects. - SECURITY WARNING: Checkpoints use pickle for data serialization. Only load checkpoints - from trusted sources. Loading a malicious checkpoint file can execute arbitrary code. + By default, checkpoint deserialization is restricted to a built-in set of safe + Python types (primitives, datetime, uuid, ...) and all ``agent_framework`` + internal types. To allow additional application-specific types, pass them via + the ``allowed_checkpoint_types`` parameter using ``"module:qualname"`` format. + + Example:: + + storage = FileCheckpointStorage( + "/tmp/checkpoints", + allowed_checkpoint_types=[ + "my_app.models:MyState", + ], + ) """ - def __init__(self, storage_path: str | Path): - """Initialize the file storage.""" + def __init__( + self, + storage_path: str | Path, + *, + allowed_checkpoint_types: list[str] | None = None, + ) -> None: + """Initialize the file storage. + + Args: + storage_path: Directory path where checkpoint files will be stored. + allowed_checkpoint_types: Additional types (beyond the built-in safe set + and framework types) that are permitted during checkpoint + deserialization. Each entry should be a ``"module:qualname"`` + string (e.g., ``"my_app.models:MyState"``). + """ self.storage_path = Path(storage_path) self.storage_path.mkdir(parents=True, exist_ok=True) + self._allowed_types: frozenset[str] = frozenset(allowed_checkpoint_types or []) logger.info(f"Initialized file checkpoint storage at {self.storage_path}") def _validate_file_path(self, checkpoint_id: CheckpointID) -> Path: @@ -327,7 +352,7 @@ class FileCheckpointStorage: from ._checkpoint_encoding import decode_checkpoint_value try: - decoded_checkpoint_dict = decode_checkpoint_value(encoded_checkpoint) + decoded_checkpoint_dict = decode_checkpoint_value(encoded_checkpoint, allowed_types=self._allowed_types) except WorkflowCheckpointException: raise checkpoint = WorkflowCheckpoint.from_dict(decoded_checkpoint_dict) @@ -352,7 +377,9 @@ class FileCheckpointStorage: encoded_checkpoint = json.load(f) from ._checkpoint_encoding import decode_checkpoint_value - decoded_checkpoint_dict = decode_checkpoint_value(encoded_checkpoint) + decoded_checkpoint_dict = decode_checkpoint_value( + encoded_checkpoint, allowed_types=self._allowed_types + ) checkpoint = WorkflowCheckpoint.from_dict(decoded_checkpoint_dict) if checkpoint.workflow_name == workflow_name: checkpoints.append(checkpoint) diff --git a/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py b/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py index 85f9327663..a25a08c66a 100644 --- a/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py +++ b/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py @@ -1,14 +1,5 @@ # Copyright (c) Microsoft. All rights reserved. -from __future__ import annotations - -import base64 -import logging -import pickle # nosec # noqa: S403 -from typing import Any - -from ..exceptions import WorkflowCheckpointException - """Checkpoint encoding using JSON structure with pickle+base64 for arbitrary data. This hybrid approach provides: @@ -16,10 +7,23 @@ This hybrid approach provides: - Full Python object fidelity via pickle for data values (non-JSON-native types) - Base64 encoding to embed binary pickle data in JSON strings -SECURITY WARNING: Checkpoints use pickle for data serialization. Only load checkpoints -from trusted sources. Loading a malicious checkpoint file can execute arbitrary code. +When ``allowed_types`` is supplied to :func:`decode_checkpoint_value`, a +``RestrictedUnpickler`` is used that limits which classes may be instantiated +during deserialization. The default built-in safe set covers common Python +value types (primitives, datetime, uuid, ...) and all ``agent_framework`` +internal types. Callers can extend the set by passing additional +``"module:qualname"`` strings. """ +from __future__ import annotations + +import base64 +import io +import logging +import pickle # nosec # noqa: S403 +from typing import Any + +from ..exceptions import WorkflowCheckpointException logger = logging.getLogger("agent_framework") @@ -30,6 +34,82 @@ _TYPE_MARKER = "__type__" # Types that are natively JSON-serializable and don't need pickling _JSON_NATIVE_TYPES = (str, int, float, bool, type(None)) +# Module prefix for framework-internal types that are always allowed +_FRAMEWORK_MODULE_PREFIX = "agent_framework." + +# Built-in types considered safe for checkpoint deserialization. +# Each entry is a ``module:qualname`` string matching the format produced by +# :func:`_type_to_key`. These are the classes for which pickle's +# ``find_class`` will be called when unpickling common Python value types. +_BUILTIN_ALLOWED_TYPE_KEYS: frozenset[str] = frozenset({ + # builtins + "builtins:object", + "builtins:complex", + "builtins:range", + "builtins:slice", + "builtins:int", + "builtins:float", + "builtins:str", + "builtins:bytes", + "builtins:bytearray", + "builtins:bool", + "builtins:set", + "builtins:frozenset", + "builtins:list", + "builtins:dict", + "builtins:tuple", + "builtins:type", + # getattr is used by pickle to reconstruct enum members + "builtins:getattr", + # copyreg helpers used by pickle for object reconstruction + "copyreg:_reconstructor", + # datetime + "datetime:datetime", + "datetime:date", + "datetime:time", + "datetime:timedelta", + "datetime:timezone", + # uuid + "uuid:UUID", + # decimal + "decimal:Decimal", + # collections + "collections:OrderedDict", + "collections:defaultdict", + "collections:deque", +}) + + +class _RestrictedUnpickler(pickle.Unpickler): # noqa: S301 + """Unpickler that restricts which classes may be instantiated. + + Only classes whose ``module:qualname`` key appears in the combined allow + set (built-in safe types + framework types + caller-specified extras) are + permitted. All other classes raise :class:`pickle.UnpicklingError`. + """ + + def __init__(self, data: bytes, allowed_types: frozenset[str]) -> None: + super().__init__(io.BytesIO(data)) + self._allowed_types = allowed_types + + def find_class(self, module: str, name: str) -> type: + type_key = f"{module}:{name}" + + if ( + type_key in _BUILTIN_ALLOWED_TYPE_KEYS + or type_key in self._allowed_types + or module.startswith(_FRAMEWORK_MODULE_PREFIX) + ): + return super().find_class(module, name) # type: ignore[no-any-return] # nosec + + raise pickle.UnpicklingError( + f"Checkpoint deserialization blocked for type '{type_key}'. " + f"To allow this type, either include its 'module:qualname' key in the " + f"'allowed_types' set passed to 'decode_checkpoint_value', or add it to " + f"'allowed_checkpoint_types' on your checkpoint storage " + f"(for example, 'FileCheckpointStorage.allowed_checkpoint_types')." + ) + def encode_checkpoint_value(value: Any) -> Any: """Encode a Python value for checkpoint storage. @@ -48,29 +128,51 @@ def encode_checkpoint_value(value: Any) -> Any: return _encode(value) -def decode_checkpoint_value(value: Any) -> Any: +def decode_checkpoint_value(value: Any, *, allowed_types: frozenset[str] | None = None) -> Any: """Decode a value from checkpoint storage. Reverses the encoding performed by encode_checkpoint_value. Pickled values (identified by _PICKLE_MARKER) are decoded and unpickled. - WARNING: Only call this with trusted data. Pickle can execute - arbitrary code during deserialization. The post-unpickle type verification - detects accidental corruption or type mismatches, but cannot prevent - arbitrary code execution from malicious pickle payloads. - Args: value: A JSON-deserialized value from checkpoint storage. + allowed_types: If not ``None``, restrict pickle deserialization to the + built-in safe set, framework types, and the types listed here. + Each entry should use ``"module:qualname"`` format — that is, the + dotted module path followed by a colon and the class + ``__qualname__``. For example, given a user-defined class:: + + # my_app/models.py + class MyState: ... + + the corresponding entry would be ``"my_app.models:MyState"``:: + + decode_checkpoint_value( + data, + allowed_types=frozenset({"my_app.models:MyState"}), + ) + + When using :class:`FileCheckpointStorage`, pass the same strings + via ``allowed_checkpoint_types``:: + + storage = FileCheckpointStorage( + "/tmp/checkpoints", + allowed_checkpoint_types=["my_app.models:MyState"], + ) + + If ``None``, no restriction is applied (backward-compatible + behavior). Returns: The original Python value. Raises: WorkflowCheckpointException: If the unpickled object's type doesn't match - the recorded type, indicating corruption, or if the base64/pickle - data is malformed. + the recorded type, indicating corruption, if the base64/pickle + data is malformed, or if a disallowed type is encountered during + restricted deserialization. """ - return _decode(value) + return _decode(value, allowed_types=allowed_types) def _encode(value: Any) -> Any: @@ -94,7 +196,7 @@ def _encode(value: Any) -> Any: } -def _decode(value: Any) -> Any: +def _decode(value: Any, *, allowed_types: frozenset[str] | None = None) -> Any: """Recursively decode a value from JSON storage.""" # JSON-native types pass through if isinstance(value, _JSON_NATIVE_TYPES): @@ -104,16 +206,16 @@ def _decode(value: Any) -> Any: if isinstance(value, dict): # Pickled value: decode, unpickle, and verify type if _PICKLE_MARKER in value and _TYPE_MARKER in value: - obj = _base64_to_unpickle(value[_PICKLE_MARKER]) # type: ignore + obj = _base64_to_unpickle(value[_PICKLE_MARKER], allowed_types=allowed_types) # type: ignore _verify_type(obj, value.get(_TYPE_MARKER)) # type: ignore return obj # Regular dict: decode values recursively - return {k: _decode(v) for k, v in value.items()} # type: ignore + return {k: _decode(v, allowed_types=allowed_types) for k, v in value.items()} # type: ignore # Handle encoded lists if isinstance(value, list): - return [_decode(item) for item in value] # type: ignore + return [_decode(item, allowed_types=allowed_types) for item in value] # type: ignore return value @@ -148,15 +250,23 @@ def _pickle_to_base64(value: Any) -> str: return base64.b64encode(pickled).decode("ascii") -def _base64_to_unpickle(encoded: str) -> Any: +def _base64_to_unpickle(encoded: str, *, allowed_types: frozenset[str] | None = None) -> Any: """Decode base64 string and unpickle. + Args: + encoded: Base64-encoded pickle data. + allowed_types: If not ``None``, use restricted unpickling that only + permits built-in safe types, framework types, and the specified + extra types. + Raises: - WorkflowCheckpointException: If the base64 data is corrupted or the pickle - format is incompatible. + WorkflowCheckpointException: If the base64 data is corrupted, the pickle + format is incompatible, or a disallowed type is encountered. """ try: pickled = base64.b64decode(encoded.encode("ascii")) + if allowed_types is not None: + return _RestrictedUnpickler(pickled, allowed_types).load() return pickle.loads(pickled) # nosec # noqa: S301 except Exception as exc: raise WorkflowCheckpointException(f"Failed to decode pickled checkpoint data: {exc}") from exc diff --git a/python/packages/core/tests/workflow/test_checkpoint.py b/python/packages/core/tests/workflow/test_checkpoint.py index a32489acc0..e395655afa 100644 --- a/python/packages/core/tests/workflow/test_checkpoint.py +++ b/python/packages/core/tests/workflow/test_checkpoint.py @@ -1048,7 +1048,10 @@ async def test_file_checkpoint_storage_roundtrip_datetime(): async def test_file_checkpoint_storage_roundtrip_dataclass(): """Test that dataclass objects roundtrip correctly via pickle encoding.""" with tempfile.TemporaryDirectory() as temp_dir: - storage = FileCheckpointStorage(temp_dir) + storage = FileCheckpointStorage( + temp_dir, + allowed_checkpoint_types=["tests.workflow.test_checkpoint:_TestCustomData"], + ) custom_obj = _TestCustomData(name="test", value=42, tags=["a", "b", "c"]) @@ -1238,7 +1241,10 @@ async def test_file_checkpoint_storage_roundtrip_messages_with_complex_data(): async def test_file_checkpoint_storage_roundtrip_pending_request_info_events(): """Test that pending_request_info_events with WorkflowEvent objects roundtrip correctly.""" with tempfile.TemporaryDirectory() as temp_dir: - storage = FileCheckpointStorage(temp_dir) + storage = FileCheckpointStorage( + temp_dir, + allowed_checkpoint_types=["tests.workflow.test_checkpoint:_TestToolApprovalRequest"], + ) # Create request_info events using the proper WorkflowEvent factory event1 = WorkflowEvent.request_info( @@ -1300,7 +1306,13 @@ async def test_file_checkpoint_storage_roundtrip_pending_request_info_events(): async def test_file_checkpoint_storage_roundtrip_full_checkpoint(): """Test complete WorkflowCheckpoint roundtrip with all fields populated using proper types.""" with tempfile.TemporaryDirectory() as temp_dir: - storage = FileCheckpointStorage(temp_dir) + storage = FileCheckpointStorage( + temp_dir, + allowed_checkpoint_types=[ + "tests.workflow.test_checkpoint:_TestApprovalRequest", + "tests.workflow.test_checkpoint:_TestExecutorState", + ], + ) # Create proper WorkflowMessage objects msg1 = WorkflowMessage(data="msg1", source_id="s", target_id="t") diff --git a/python/packages/core/tests/workflow/test_checkpoint_unrestricted_pickle.py b/python/packages/core/tests/workflow/test_checkpoint_unrestricted_pickle.py new file mode 100644 index 0000000000..c70d8c85c3 --- /dev/null +++ b/python/packages/core/tests/workflow/test_checkpoint_unrestricted_pickle.py @@ -0,0 +1,218 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for restricted checkpoint deserialization. + +These tests verify that persisted checkpoint loading uses a restricted +unpickler by default: +- Arbitrary callables are blocked during deserialization +- __reduce__ payloads cannot execute code during deserialization +- FileCheckpointStorage accepts allowed_checkpoint_types for extension +- User-defined types are blocked unless explicitly allowed +- Built-in safe types and framework types are always allowed +""" + +import base64 +import os +import pickle +import tempfile +from dataclasses import dataclass +from datetime import datetime, timezone + +import pytest + +from agent_framework import WorkflowCheckpointException +from agent_framework._workflows._checkpoint import FileCheckpointStorage +from agent_framework._workflows._checkpoint_encoding import ( + _PICKLE_MARKER, + _TYPE_MARKER, + decode_checkpoint_value, + encode_checkpoint_value, +) + + +class MaliciousPayload: + """A class whose __reduce__ executes code during unpickling.""" + + def __reduce__(self): + return (os.getpid, ()) + + +def test_restricted_decode_blocks_arbitrary_callable(): + """Restricted decoding blocks arbitrary module-level callables.""" + pickled = pickle.dumps(os.getpid, protocol=pickle.HIGHEST_PROTOCOL) + encoded_b64 = base64.b64encode(pickled).decode("ascii") + + checkpoint_value = { + _PICKLE_MARKER: encoded_b64, + _TYPE_MARKER: "builtins:builtin_function_or_method", + } + + with pytest.raises(WorkflowCheckpointException, match="deserialization blocked"): + decode_checkpoint_value(checkpoint_value, allowed_types=frozenset()) + + +def test_restricted_decode_blocks_reduce_payload(): + """__reduce__-based payloads are blocked before code can execute.""" + payload = MaliciousPayload() + pickled = pickle.dumps(payload, protocol=pickle.HIGHEST_PROTOCOL) + encoded_b64 = base64.b64encode(pickled).decode("ascii") + + checkpoint_value = { + _PICKLE_MARKER: encoded_b64, + _TYPE_MARKER: f"{MaliciousPayload.__module__}:{MaliciousPayload.__qualname__}", + } + + with pytest.raises(WorkflowCheckpointException, match="deserialization blocked"): + decode_checkpoint_value(checkpoint_value, allowed_types=frozenset()) + + +def test_restricted_decode_prevents_code_execution(): + """Restricted deserialization prevents __reduce__ code from running.""" + with tempfile.TemporaryDirectory() as tmpdir: + marker_file = os.path.join(tmpdir, "checkpoint_test_marker") + + payload_bytes = pickle.dumps( + type( + "Exploit", + (), + { + "__reduce__": lambda self: ( + eval, + (f"open({marker_file!r}, 'w').write('pwned')",), + ) + }, + )(), + protocol=pickle.HIGHEST_PROTOCOL, + ) + encoded_b64 = base64.b64encode(payload_bytes).decode("ascii") + + checkpoint_value = { + _PICKLE_MARKER: encoded_b64, + _TYPE_MARKER: "builtins:int", + } + with pytest.raises(WorkflowCheckpointException, match="deserialization blocked"): + decode_checkpoint_value(checkpoint_value, allowed_types=frozenset()) + + assert not os.path.exists(marker_file), ( + "Restricted unpickler should have prevented code execution, but the marker file was created." + ) + + +def test_file_checkpoint_storage_accepts_allowed_types(): + """FileCheckpointStorage.__init__ accepts allowed_checkpoint_types.""" + with tempfile.TemporaryDirectory() as tmpdir: + storage = FileCheckpointStorage( + tmpdir, + allowed_checkpoint_types=["some.module:SomeType"], + ) + assert storage is not None + + +@dataclass +class _AllowedTestState: + """Test dataclass that will be explicitly allowed.""" + + name: str + value: int + + +def test_restricted_decode_blocks_unlisted_user_type(): + """User-defined types are blocked when not in allowed_checkpoint_types.""" + original = _AllowedTestState(name="test", value=42) + encoded = encode_checkpoint_value(original) + + with pytest.raises(WorkflowCheckpointException, match="deserialization blocked"): + decode_checkpoint_value(encoded, allowed_types=frozenset()) + + +def test_restricted_decode_allows_listed_user_type(): + """User-defined types are allowed when listed in allowed_types.""" + original = _AllowedTestState(name="test", value=42) + encoded = encode_checkpoint_value(original) + + type_key = f"{_AllowedTestState.__module__}:{_AllowedTestState.__qualname__}" + decoded = decode_checkpoint_value(encoded, allowed_types=frozenset({type_key})) + + assert isinstance(decoded, _AllowedTestState) + assert decoded.name == "test" + assert decoded.value == 42 + + +def test_restricted_decode_allows_builtin_safe_types(): + """Built-in safe types (datetime, set, etc.) are always allowed.""" + test_values = [ + datetime(2025, 1, 1, tzinfo=timezone.utc), + {1, 2, 3}, + frozenset({4, 5, 6}), + (1, "two", 3.0), + complex(1, 2), + ] + for original in test_values: + encoded = encode_checkpoint_value(original) + decoded = decode_checkpoint_value(encoded, allowed_types=frozenset()) + assert decoded == original + + +def test_unrestricted_decode_allows_arbitrary_types(): + """Without allowed_types, decode_checkpoint_value remains unrestricted.""" + original = _AllowedTestState(name="test", value=42) + encoded = encode_checkpoint_value(original) + + decoded = decode_checkpoint_value(encoded) + + assert isinstance(decoded, _AllowedTestState) + assert decoded.name == "test" + + +async def test_file_storage_blocks_unlisted_user_type(): + """FileCheckpointStorage blocks user types not in allowed_checkpoint_types.""" + from agent_framework import WorkflowCheckpoint + + with tempfile.TemporaryDirectory() as tmpdir: + # Save with a storage that allows the type + type_key = f"{_AllowedTestState.__module__}:{_AllowedTestState.__qualname__}" + save_storage = FileCheckpointStorage(tmpdir, allowed_checkpoint_types=[type_key]) + + checkpoint = WorkflowCheckpoint( + workflow_name="test", + graph_signature_hash="hash", + state={"data": _AllowedTestState(name="test", value=1)}, + ) + await save_storage.save(checkpoint) + + # Load with a storage that does NOT allow the type + load_storage = FileCheckpointStorage(tmpdir) + with pytest.raises(WorkflowCheckpointException, match="deserialization blocked"): + await load_storage.load(checkpoint.checkpoint_id) + + +async def test_file_storage_allows_listed_user_type(): + """FileCheckpointStorage allows user types listed in allowed_checkpoint_types.""" + from agent_framework import WorkflowCheckpoint + + with tempfile.TemporaryDirectory() as tmpdir: + type_key = f"{_AllowedTestState.__module__}:{_AllowedTestState.__qualname__}" + storage = FileCheckpointStorage(tmpdir, allowed_checkpoint_types=[type_key]) + + checkpoint = WorkflowCheckpoint( + workflow_name="test", + graph_signature_hash="hash", + state={"data": _AllowedTestState(name="allowed", value=99)}, + ) + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + assert isinstance(loaded.state["data"], _AllowedTestState) + assert loaded.state["data"].name == "allowed" + assert loaded.state["data"].value == 99 + + +def test_restricted_unpickler_raises_pickle_error(): + """_RestrictedUnpickler.find_class raises pickle.UnpicklingError, not a framework exception.""" + from agent_framework._workflows._checkpoint_encoding import _RestrictedUnpickler + + pickled = pickle.dumps(os.getpid, protocol=pickle.HIGHEST_PROTOCOL) + + unpickler = _RestrictedUnpickler(pickled, frozenset()) + with pytest.raises(pickle.UnpicklingError, match="deserialization blocked"): + unpickler.load() diff --git a/python/packages/core/tests/workflow/test_request_info_event_rehydrate.py b/python/packages/core/tests/workflow/test_request_info_event_rehydrate.py index 9400084692..d280a788cd 100644 --- a/python/packages/core/tests/workflow/test_request_info_event_rehydrate.py +++ b/python/packages/core/tests/workflow/test_request_info_event_rehydrate.py @@ -130,7 +130,17 @@ async def test_checkpoint_with_pending_request_info_events(): with tempfile.TemporaryDirectory() as temp_dir: # Use file-based storage to test full serialization - storage = FileCheckpointStorage(temp_dir) + storage = FileCheckpointStorage( + temp_dir, + allowed_checkpoint_types=[ + "tests.workflow.test_request_info_and_response:UserApprovalRequest", + "tests.workflow.test_request_info_and_response:CalculationRequest", + "tests.workflow.test_request_info_event_rehydrate:MockRequest", + "tests.workflow.test_request_info_event_rehydrate:SimpleApproval", + "tests.workflow.test_request_info_event_rehydrate:SlottedApproval", + "tests.workflow.test_request_info_event_rehydrate:TimedApproval", + ], + ) # Create workflow with checkpointing enabled executor = ApprovalRequiredExecutor(id="approval_executor") @@ -225,7 +235,17 @@ async def test_checkpoint_restore_with_responses_does_not_reemit_handled_request with tempfile.TemporaryDirectory() as temp_dir: # Use file-based storage to test full serialization - storage = FileCheckpointStorage(temp_dir) + storage = FileCheckpointStorage( + temp_dir, + allowed_checkpoint_types=[ + "tests.workflow.test_request_info_and_response:UserApprovalRequest", + "tests.workflow.test_request_info_and_response:CalculationRequest", + "tests.workflow.test_request_info_event_rehydrate:MockRequest", + "tests.workflow.test_request_info_event_rehydrate:SimpleApproval", + "tests.workflow.test_request_info_event_rehydrate:SlottedApproval", + "tests.workflow.test_request_info_event_rehydrate:TimedApproval", + ], + ) # Create workflow with checkpointing enabled executor = ApprovalRequiredExecutor(id="approval_executor") @@ -288,7 +308,17 @@ async def test_checkpoint_restore_with_partial_responses_reemits_unhandled_reque import tempfile with tempfile.TemporaryDirectory() as temp_dir: - storage = FileCheckpointStorage(temp_dir) + storage = FileCheckpointStorage( + temp_dir, + allowed_checkpoint_types=[ + "tests.workflow.test_request_info_and_response:UserApprovalRequest", + "tests.workflow.test_request_info_and_response:CalculationRequest", + "tests.workflow.test_request_info_event_rehydrate:MockRequest", + "tests.workflow.test_request_info_event_rehydrate:SimpleApproval", + "tests.workflow.test_request_info_event_rehydrate:SlottedApproval", + "tests.workflow.test_request_info_event_rehydrate:TimedApproval", + ], + ) # Create workflow with multiple requests executor = MultiRequestExecutor(id="multi_executor") diff --git a/python/pytest.xml b/python/pytest.xml new file mode 100644 index 0000000000..13d4791f5a --- /dev/null +++ b/python/pytest.xml @@ -0,0 +1 @@ +/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/core/test_observability.py:592: Skipping OTLP exporter tests - optional dependency not installed by default/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/core/test_observability.py:609: Skipping OTLP exporter tests - optional dependency not installed by default/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/core/test_observability.py:626: Skipping OTLP exporter tests - optional dependency not installed by default/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/core/test_observability.py:645: Skipping OTLP exporter tests - optional dependency not installed by default/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/core/test_observability.py:663: Skipping OTLP exporter tests - optional dependency not installed by default/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/core/test_observability.py:681: Skipping OTLP exporter tests - optional dependency not installed by default/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/core/test_observability.py:704: Skipping OTLP exporter tests - optional dependency not installed by default/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/core/test_observability.py:776: Skipping OTLP exporter tests - optional dependency not installed by default/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/core/test_observability.py:790: Skipping OTLP exporter tests - optional dependency not installed by default/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/core/test_observability.py:804: Skipping OTLP exporter tests - optional dependency not installed by default/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/core/test_observability.py:823: Skipping OTLP exporter tests - optional dependency not installed by default/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/core/test_observability.py:839: Skipping OTLP exporter tests - optional dependency not installed by default/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/core/test_observability.py:861: Skipping OTLP exporter tests - optional dependency not installed by default/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/core/test_observability.py:888: Skipping OTLP exporter tests - optional dependency not installed by default/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/core/test_function_invocation_logic.py:1463: Error handling and failsafe behavior needs investigation in unified API/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/core/test_function_invocation_logic.py:2885: Failsafe behavior needs investigation in unified API/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/declarative/tests/test_powerfx_yaml_compatibility.py:521: No non-English LC_NUMERIC locale available on this system/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/workflow/test_viz.py:160: Requires graphviz to be installed/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/workflow/test_viz.py:195: could not import 'graphviz': No module named 'graphviz' \ No newline at end of file diff --git a/python/python-coverage.xml b/python/python-coverage.xml new file mode 100644 index 0000000000..15d32ded01 --- /dev/null +++ b/python/python-coverage.xml @@ -0,0 +1,28040 @@ + + + + + + /repos/agent-framework/.worktrees/agent/fix-4894-1/python + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From eea543e697a6f84602f420cb6e06c087d985da99 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:11:48 -0700 Subject: [PATCH 09/22] Bump vite (#5132) Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.1 to 7.3.2. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v7.3.2/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v7.3.2/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 7.3.2 dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../ag_ui_workflow_handoff/frontend/package-lock.json | 8 ++++---- .../ag_ui_workflow_handoff/frontend/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package-lock.json b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package-lock.json index 991211fafd..a78c2a9196 100644 --- a/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package-lock.json +++ b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package-lock.json @@ -17,7 +17,7 @@ "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", "typescript": "^5.5.4", - "vite": "^7.3.1" + "vite": "^7.3.2" } }, "node_modules/@babel/code-frame": { @@ -1776,9 +1776,9 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package.json b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package.json index 75af8fcf94..e839a5e9ec 100644 --- a/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package.json +++ b/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend/package.json @@ -18,6 +18,6 @@ "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", "typescript": "^5.5.4", - "vite": "^7.3.1" + "vite": "^7.3.2" } } From e8757cebde0fb766b87c055bfc9d0a567ea1881f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 00:13:32 +0000 Subject: [PATCH 10/22] Bump cryptography from 46.0.6 to 46.0.7 in /python (#5176) Bumps [cryptography](https://github.com/pyca/cryptography) from 46.0.6 to 46.0.7. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/46.0.6...46.0.7) --- updated-dependencies: - dependency-name: cryptography dependency-version: 46.0.7 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> --- python/uv.lock | 100 ++++++++++++++++++++++++------------------------- 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/python/uv.lock b/python/uv.lock index 6c0af082d4..f15709ea21 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1739,62 +1739,62 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.6" +version = "46.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (platform_python_implementation != 'PyPy' and sys_platform == 'win32')" }, { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" }, - { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" }, - { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" }, - { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" }, - { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" }, - { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" }, - { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" }, - { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" }, - { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" }, - { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" }, - { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" }, - { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" }, - { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" }, - { url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" }, - { url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" }, - { url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" }, - { url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" }, - { url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" }, - { url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" }, - { url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" }, - { url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" }, - { url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" }, - { url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" }, - { url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" }, - { url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" }, - { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" }, - { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" }, - { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" }, - { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" }, - { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" }, - { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" }, - { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" }, - { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" }, - { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" }, - { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" }, - { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" }, - { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" }, - { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" }, - { url = "https://files.pythonhosted.org/packages/2e/84/7ccff00ced5bac74b775ce0beb7d1be4e8637536b522b5df9b73ada42da2/cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead", size = 3475444, upload-time = "2026-03-25T23:34:38.944Z" }, - { url = "https://files.pythonhosted.org/packages/bc/1f/4c926f50df7749f000f20eede0c896769509895e2648db5da0ed55db711d/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8", size = 4218227, upload-time = "2026-03-25T23:34:40.871Z" }, - { url = "https://files.pythonhosted.org/packages/c6/65/707be3ffbd5f786028665c3223e86e11c4cda86023adbc56bd72b1b6bab5/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0", size = 4381399, upload-time = "2026-03-25T23:34:42.609Z" }, - { url = "https://files.pythonhosted.org/packages/f3/6d/73557ed0ef7d73d04d9aba745d2c8e95218213687ee5e76b7d236a5030fc/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b", size = 4217595, upload-time = "2026-03-25T23:34:44.205Z" }, - { url = "https://files.pythonhosted.org/packages/9e/c5/e1594c4eec66a567c3ac4400008108a415808be2ce13dcb9a9045c92f1a0/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a", size = 4380912, upload-time = "2026-03-25T23:34:46.328Z" }, - { url = "https://files.pythonhosted.org/packages/1a/89/843b53614b47f97fe1abc13f9a86efa5ec9e275292c457af1d4a60dc80e0/cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e", size = 3409955, upload-time = "2026-03-25T23:34:48.465Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, + { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, + { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, + { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, + { url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879, upload-time = "2026-04-08T01:57:38.664Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" }, + { url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" }, + { url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" }, ] [[package]] From a172313ec31f17cb47fdbb46d30e5940f594d90e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 09:13:40 +0900 Subject: [PATCH 11/22] Bump mcp[ws] from 1.26.0 to 1.27.0 in /python (#5119) Bumps [mcp[ws]](https://github.com/modelcontextprotocol/python-sdk) from 1.26.0 to 1.27.0. - [Release notes](https://github.com/modelcontextprotocol/python-sdk/releases) - [Changelog](https://github.com/modelcontextprotocol/python-sdk/blob/main/RELEASE.md) - [Commits](https://github.com/modelcontextprotocol/python-sdk/compare/v1.26.0...v1.27.0) --- updated-dependencies: - dependency-name: mcp[ws] dependency-version: 1.27.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- python/pyproject.toml | 2 +- python/uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index 24af13b940..06391b8b59 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -39,7 +39,7 @@ dev = [ "pytest-retry==1.7.0", "mypy==1.20.0", "pyright==1.1.408", - "mcp[ws]==1.26.0", + "mcp[ws]==1.27.0", "opentelemetry-sdk==1.40.0", #tasks "poethepoet==0.42.1", diff --git a/python/uv.lock b/python/uv.lock index f15709ea21..3430e084e4 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -126,7 +126,7 @@ requires-dist = [{ name = "agent-framework-core", extras = ["all"], editable = " [package.metadata.requires-dev] dev = [ { name = "flit", specifier = "==3.12.0" }, - { name = "mcp", extras = ["ws"], specifier = "==1.26.0" }, + { name = "mcp", extras = ["ws"], specifier = "==1.27.0" }, { name = "mypy", specifier = "==1.20.0" }, { name = "opentelemetry-sdk", specifier = "==1.40.0" }, { name = "poethepoet", specifier = "==0.42.1" }, @@ -3428,7 +3428,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.26.0" +version = "1.27.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3446,9 +3446,9 @@ dependencies = [ { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "uvicorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, + { url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" }, ] [package.optional-dependencies] From 790a759dbfee6ea0bffc3ccd23d8a4d94c661da7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 09:13:45 +0900 Subject: [PATCH 12/22] Bump mcp from 1.26.0 to 1.27.0 in /python (#5117) Bumps [mcp](https://github.com/modelcontextprotocol/python-sdk) from 1.26.0 to 1.27.0. - [Release notes](https://github.com/modelcontextprotocol/python-sdk/releases) - [Changelog](https://github.com/modelcontextprotocol/python-sdk/blob/main/RELEASE.md) - [Commits](https://github.com/modelcontextprotocol/python-sdk/compare/v1.26.0...v1.27.0) --- updated-dependencies: - dependency-name: mcp dependency-version: 1.27.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> From d4036c5aefeac5c6faf22450345c49ebb123c281 Mon Sep 17 00:00:00 2001 From: Dineshsuriya D <43177361+droideronline@users.noreply.github.com> Date: Fri, 10 Apr 2026 06:37:14 +0530 Subject: [PATCH 13/22] Python: Migrate GitHub Copilot package to SDK 0.2.x (#5107) * Python: Migrate GitHub Copilot package to SDK 0.2.x Replace all imports from the non-existent copilot.types module with correct SDK 0.2.x module paths (copilot.session, copilot.client, copilot.tools, copilot.generated.session_events). Fix PermissionRequest attribute access from dict-style .get() to dataclass attribute access. Add OTel telemetry support to Copilot samples via configure_otel_providers and document new telemetry environment variables in samples README. * Python: Fix remaining copilot.types import in sample validation script * Python: Include model in default_options for telemetry span attributes * Python: Address review feedback on log_level and session kwargs typing * Python: Scope PR to SDK 0.2.x migration only, remove net-new OTel features - Remove RawGitHubCopilotAgent split and AgentTelemetryLayer inheritance - Remove TelemetryConfig plumbing and OTLP/file telemetry settings - Remove configure_otel_providers() calls from samples - Remove telemetry env var rows from samples README - Retain only: import path fixes, PermissionRequest attribute access fix, log_level default fix, session kwargs typed fix, dependency pin * Python: Update tests for SDK 0.2.x API changes - SubprocessConfig replaces CopilotClientOptions dict - create_session and resume_session now use keyword args - send and send_and_wait take plain string prompt instead of MessageOptions - on_permission_request is always required; deny-all fallback replaces omission * Python: Pin github-copilot-sdk to >=0.2.0,<=0.2.0 Tighten the upper bound from <0.3.0 to <=0.2.0 to avoid pulling in 0.2.1+ which has breaking API changes relative to 0.2.0. The lower bound stays at >=0.2.0 since this migration requires the 0.2.x import paths; 0.1.x would fail at import time. * Python: Pin github-copilot-sdk to >=0.2.1,<=0.2.1 --------- Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> --- .../agent_framework_github_copilot/_agent.py | 102 ++++++++---------- python/packages/github_copilot/pyproject.toml | 3 +- .../tests/test_github_copilot_agent.py | 86 ++++++++------- .../github_copilot/github_copilot_basic.py | 2 +- .../github_copilot_with_file_operations.py | 2 +- .../github_copilot/github_copilot_with_mcp.py | 2 +- ...ithub_copilot_with_multiple_permissions.py | 2 +- .../github_copilot_with_session.py | 2 +- .../github_copilot_with_shell.py | 2 +- .../github_copilot/github_copilot_with_url.py | 2 +- .../create_dynamic_workflow_executor.py | 2 +- python/uv.lock | 16 +-- 12 files changed, 112 insertions(+), 111 deletions(-) diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index 4599cd3526..4744013b56 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -7,7 +7,7 @@ import contextlib import logging import sys from collections.abc import AsyncIterable, Awaitable, Callable, MutableMapping, Sequence -from typing import Any, ClassVar, Generic, Literal, TypedDict, cast, overload +from typing import Any, ClassVar, Generic, Literal, TypedDict, overload from agent_framework import ( AgentMiddlewareTypes, @@ -29,20 +29,11 @@ from agent_framework._types import AgentRunInputs, normalize_tools from agent_framework.exceptions import AgentException try: - from copilot import CopilotClient, CopilotSession + from copilot import CopilotClient, CopilotSession, SubprocessConfig from copilot.generated.session_events import PermissionRequest, SessionEvent, SessionEventType - from copilot.types import ( - CopilotClientOptions, - MCPServerConfig, - MessageOptions, - PermissionRequestResult, - ResumeSessionConfig, - SessionConfig, - SystemMessageConfig, - ToolInvocation, - ToolResult, - ) - from copilot.types import Tool as CopilotTool + from copilot.session import MCPServerConfig, PermissionRequestResult, SystemMessageConfig + from copilot.tools import Tool as CopilotTool + from copilot.tools import ToolInvocation, ToolResult except ImportError as _copilot_import_error: raise ImportError( "GitHubCopilotAgent requires the 'github-copilot-sdk' package, which is only available on Python 3.11+. " @@ -64,6 +55,14 @@ PermissionHandlerType = Callable[[PermissionRequest, dict[str, str]], Permission logger = logging.getLogger("agent_framework.github_copilot") +def _deny_all_permissions( + _request: PermissionRequest, + _invocation: dict[str, str], +) -> PermissionRequestResult: + """Default permission handler that denies all requests.""" + return PermissionRequestResult() + + class GitHubCopilotSettings(TypedDict, total=False): """GitHub Copilot model settings. @@ -274,16 +273,13 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): return if self._client is None: - client_options: CopilotClientOptions = {} - cli_path = self._settings.get("cli_path") - if cli_path: - client_options["cli_path"] = cli_path + cli_path = self._settings.get("cli_path") or None + log_level = self._settings.get("log_level") or None - log_level = self._settings.get("log_level") + subprocess_kwargs: dict[str, Any] = {"cli_path": cli_path} if log_level: - client_options["log_level"] = log_level # type: ignore[typeddict-item] - - self._client = CopilotClient(client_options if client_options else None) + subprocess_kwargs["log_level"] = log_level + self._client = CopilotClient(SubprocessConfig(**subprocess_kwargs)) try: await self._client.start() @@ -407,10 +403,9 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): prompt = "\n".join([message.text for message in context_messages]) if session_context.instructions: prompt = "\n".join(session_context.instructions) + "\n" + prompt - message_options = cast(MessageOptions, {"prompt": prompt}) try: - response_event = await copilot_session.send_and_wait(message_options, timeout=timeout) + response_event = await copilot_session.send_and_wait(prompt, timeout=timeout) except Exception as ex: raise AgentException(f"GitHub Copilot request failed: {ex}") from ex @@ -489,7 +484,6 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): prompt = "\n".join([message.text for message in context_messages]) if session_context.instructions: prompt = "\n".join(session_context.instructions) + "\n" + prompt - message_options = cast(MessageOptions, {"prompt": prompt}) queue: asyncio.Queue[AgentResponseUpdate | Exception | None] = asyncio.Queue() @@ -550,7 +544,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): unsubscribe = copilot_session.on(event_handler) try: - await copilot_session.send(message_options) + await copilot_session.send(prompt) while (item := await queue.get()) is not None: if isinstance(item, Exception): @@ -730,43 +724,35 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): raise RuntimeError("GitHub Copilot client not initialized. Call start() first.") opts = runtime_options or {} - config: SessionConfig = {"streaming": streaming} + model = opts.get("model") or self._settings.get("model") or None + system_message = opts.get("system_message") or self._default_options.get("system_message") or None + permission_handler: PermissionHandlerType = ( + opts.get("on_permission_request") or self._permission_handler or _deny_all_permissions + ) + mcp_servers = opts.get("mcp_servers") or self._mcp_servers or None + tools = self._prepare_tools(self._tools) if self._tools else None - model = opts.get("model") or self._settings.get("model") - if model: - config["model"] = model # type: ignore[typeddict-item] - - system_message = opts.get("system_message") or self._default_options.get("system_message") - if system_message: - config["system_message"] = system_message - - if self._tools: - config["tools"] = self._prepare_tools(self._tools) - - permission_handler = opts.get("on_permission_request") or self._permission_handler - if permission_handler: - config["on_permission_request"] = permission_handler - - mcp_servers = opts.get("mcp_servers") or self._mcp_servers - if mcp_servers: - config["mcp_servers"] = mcp_servers - - return await self._client.create_session(config) + return await self._client.create_session( + on_permission_request=permission_handler, + streaming=streaming, + model=model or None, + system_message=system_message or None, + tools=tools or None, + mcp_servers=mcp_servers or None, + ) async def _resume_session(self, session_id: str, streaming: bool) -> CopilotSession: """Resume an existing Copilot session by ID.""" if not self._client: raise RuntimeError("GitHub Copilot client not initialized. Call start() first.") - config: ResumeSessionConfig = {"streaming": streaming} + permission_handler: PermissionHandlerType = self._permission_handler or _deny_all_permissions + tools = self._prepare_tools(self._tools) if self._tools else None - if self._tools: - config["tools"] = self._prepare_tools(self._tools) - - if self._permission_handler: - config["on_permission_request"] = self._permission_handler - - if self._mcp_servers: - config["mcp_servers"] = self._mcp_servers - - return await self._client.resume_session(session_id, config) + return await self._client.resume_session( + session_id, + on_permission_request=permission_handler, + streaming=streaming, + tools=tools or None, + mcp_servers=self._mcp_servers or None, + ) diff --git a/python/packages/github_copilot/pyproject.toml b/python/packages/github_copilot/pyproject.toml index aecdaa02f9..2a71d577af 100644 --- a/python/packages/github_copilot/pyproject.toml +++ b/python/packages/github_copilot/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.0.0,<2", - "github-copilot-sdk>=0.1.31,<0.1.33; python_version >= '3.11'", + "github-copilot-sdk>=0.2.1,<=0.2.1; python_version >= '3.11'", ] [tool.uv] @@ -102,3 +102,4 @@ interpreter = "posix" [build-system] requires = ["flit-core >= 3.11,<4.0"] build-backend = "flit_core.buildapi" + diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index e91a725765..17e9432e40 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -23,7 +23,7 @@ from agent_framework import ( ) from agent_framework.exceptions import AgentException from copilot.generated.session_events import Data, ErrorClass, Result, SessionEvent, SessionEventType -from copilot.types import ToolInvocation, ToolResult +from copilot.tools import ToolInvocation, ToolResult from agent_framework_github_copilot import GitHubCopilotAgent, GitHubCopilotOptions @@ -268,8 +268,8 @@ class TestGitHubCopilotAgentLifecycle: await agent.start() call_args = MockClient.call_args[0][0] - assert call_args["cli_path"] == "/custom/path" - assert call_args["log_level"] == "debug" + assert call_args.cli_path == "/custom/path" + assert call_args.log_level == "debug" class TestGitHubCopilotAgentRun: @@ -855,7 +855,13 @@ class TestGitHubCopilotAgentSessionManagement: await agent.run("World", session=session) mock_client.create_session.assert_called_once() - mock_client.resume_session.assert_called_once_with(mock_session.session_id, unittest.mock.ANY) + mock_client.resume_session.assert_called_once_with( + mock_session.session_id, + on_permission_request=unittest.mock.ANY, + streaming=unittest.mock.ANY, + tools=unittest.mock.ANY, + mcp_servers=unittest.mock.ANY, + ) async def test_session_config_includes_model( self, @@ -871,7 +877,7 @@ class TestGitHubCopilotAgentSessionManagement: await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs assert config["model"] == "claude-sonnet-4" async def test_session_config_includes_instructions( @@ -889,7 +895,7 @@ class TestGitHubCopilotAgentSessionManagement: await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs assert config["system_message"]["mode"] == "append" assert config["system_message"]["content"] == "You are a helpful assistant." @@ -914,7 +920,7 @@ class TestGitHubCopilotAgentSessionManagement: ) call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs assert config["system_message"]["mode"] == "replace" assert config["system_message"]["content"] == "Runtime instructions" @@ -930,7 +936,7 @@ class TestGitHubCopilotAgentSessionManagement: await agent._get_or_create_session(AgentSession(), streaming=True) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs assert config["streaming"] is True async def test_resume_session_with_existing_service_session_id( @@ -958,7 +964,8 @@ class TestGitHubCopilotAgentSessionManagement: mock_session: MagicMock, ) -> None: """Test that resumed session config includes tools and permission handler.""" - from copilot.types import PermissionRequest, PermissionRequestResult + from copilot.generated.session_events import PermissionRequest + from copilot.session import PermissionRequestResult def my_handler(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: return PermissionRequestResult(kind="approved") @@ -981,7 +988,7 @@ class TestGitHubCopilotAgentSessionManagement: mock_client.resume_session.assert_called_once() call_args = mock_client.resume_session.call_args - config = call_args[0][1] + config = call_args.kwargs assert "tools" in config assert "on_permission_request" in config @@ -995,7 +1002,7 @@ class TestGitHubCopilotAgentMCPServers: mock_session: MagicMock, ) -> None: """Test that mcp_servers are passed through to create_session config.""" - from copilot.types import MCPServerConfig + from copilot.session import MCPServerConfig mcp_servers: dict[str, MCPServerConfig] = { "filesystem": { @@ -1020,7 +1027,7 @@ class TestGitHubCopilotAgentMCPServers: await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs assert "mcp_servers" in config assert "filesystem" in config["mcp_servers"] assert "remote" in config["mcp_servers"] @@ -1033,7 +1040,7 @@ class TestGitHubCopilotAgentMCPServers: mock_session: MagicMock, ) -> None: """Test that mcp_servers are passed through to resume_session config.""" - from copilot.types import MCPServerConfig + from copilot.session import MCPServerConfig mcp_servers: dict[str, MCPServerConfig] = { "test-server": { @@ -1057,7 +1064,7 @@ class TestGitHubCopilotAgentMCPServers: mock_client.resume_session.assert_called_once() call_args = mock_client.resume_session.call_args - config = call_args[0][1] + config = call_args.kwargs assert "mcp_servers" in config assert "test-server" in config["mcp_servers"] @@ -1073,8 +1080,8 @@ class TestGitHubCopilotAgentMCPServers: await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] - assert "mcp_servers" not in config + config = call_args.kwargs + assert config["mcp_servers"] is None class TestGitHubCopilotAgentToolConversion: @@ -1097,7 +1104,7 @@ class TestGitHubCopilotAgentToolConversion: await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs assert "tools" in config assert len(config["tools"]) == 1 assert config["tools"][0].name == "my_tool" @@ -1120,7 +1127,7 @@ class TestGitHubCopilotAgentToolConversion: await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs copilot_tool = config["tools"][0] result = await copilot_tool.handler(ToolInvocation(arguments={"arg": "test"})) @@ -1146,7 +1153,7 @@ class TestGitHubCopilotAgentToolConversion: await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs copilot_tool = config["tools"][0] result = await copilot_tool.handler(ToolInvocation(arguments={"arg": "test"})) @@ -1173,7 +1180,7 @@ class TestGitHubCopilotAgentToolConversion: await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs copilot_tool = config["tools"][0] with pytest.raises((TypeError, AttributeError)): @@ -1196,7 +1203,7 @@ class TestGitHubCopilotAgentToolConversion: await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs copilot_tool = config["tools"][0] result = await copilot_tool.handler(ToolInvocation(arguments={})) @@ -1210,7 +1217,7 @@ class TestGitHubCopilotAgentToolConversion: mock_client: MagicMock, ) -> None: """Test that CopilotTool instances are passed through as-is.""" - from copilot.types import Tool as CopilotTool + from copilot.tools import Tool as CopilotTool async def tool_handler(invocation: Any) -> Any: return {"text_result_for_llm": "result", "result_type": "success"} @@ -1234,7 +1241,7 @@ class TestGitHubCopilotAgentToolConversion: ) -> None: """Test that mixed tool types are handled correctly.""" from agent_framework import tool - from copilot.types import Tool as CopilotTool + from copilot.tools import Tool as CopilotTool @tool(approval_mode="never_require") def my_function(arg: str) -> str: @@ -1317,10 +1324,11 @@ class TestGitHubCopilotAgentPermissions: def test_permission_handler_set_when_provided(self) -> None: """Test that a handler is set when on_permission_request is provided.""" - from copilot.types import PermissionRequest, PermissionRequestResult + from copilot.generated.session_events import PermissionRequest + from copilot.session import PermissionRequestResult def approve_shell(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: - if request.get("kind") == "shell": + if request.kind == "shell": return PermissionRequestResult(kind="approved") return PermissionRequestResult(kind="denied-interactively-by-user") @@ -1335,10 +1343,11 @@ class TestGitHubCopilotAgentPermissions: mock_session: MagicMock, ) -> None: """Test that session config includes permission handler when provided.""" - from copilot.types import PermissionRequest, PermissionRequestResult + from copilot.generated.session_events import PermissionRequest + from copilot.session import PermissionRequestResult def approve_shell_read(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: - if request.get("kind") in ("shell", "read"): + if request.kind in ("shell", "read"): return PermissionRequestResult(kind="approved") return PermissionRequestResult(kind="denied-interactively-by-user") @@ -1351,24 +1360,29 @@ class TestGitHubCopilotAgentPermissions: await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] + config = call_args.kwargs assert "on_permission_request" in config assert config["on_permission_request"] is not None - async def test_session_config_excludes_permission_handler_when_not_set( + async def test_session_config_uses_deny_all_when_no_permission_handler_set( self, mock_client: MagicMock, mock_session: MagicMock, ) -> None: - """Test that session config does not include permission handler when not set.""" + """Test that session config uses deny-all handler when no permission handler is set. + + In SDK 0.2.x, on_permission_request is required by create_session, so the agent + always falls back to _deny_all_permissions when no handler is provided. + """ agent = GitHubCopilotAgent(client=mock_client) await agent.start() await agent._get_or_create_session(AgentSession()) # type: ignore call_args = mock_client.create_session.call_args - config = call_args[0][0] - assert "on_permission_request" not in config + config = call_args.kwargs + assert "on_permission_request" in config + assert config["on_permission_request"] is not None class SpyContextProvider(ContextProvider): @@ -1454,7 +1468,7 @@ class TestGitHubCopilotAgentContextProviders: session = agent.create_session() await agent.run("Hello", session=session) - sent_prompt = mock_session.send_and_wait.call_args[0][0]["prompt"] + sent_prompt = mock_session.send_and_wait.call_args[0][0] assert "Injected by spy provider" in sent_prompt async def test_after_run_receives_response( @@ -1547,7 +1561,7 @@ class TestGitHubCopilotAgentContextProviders: async for _ in agent.run("Hello", stream=True, session=session): pass - sent_prompt = mock_session.send.call_args[0][0]["prompt"] + sent_prompt = mock_session.send.call_args[0][0] assert "Injected by spy provider" in sent_prompt async def test_context_preserved_across_runs( @@ -1608,7 +1622,7 @@ class TestGitHubCopilotAgentContextProviders: session = agent.create_session() await agent.run("Hello", session=session) - sent_prompt = mock_session.send_and_wait.call_args[0][0]["prompt"] + sent_prompt = mock_session.send_and_wait.call_args[0][0] assert "History message" in sent_prompt assert "Hello" in sent_prompt @@ -1659,7 +1673,7 @@ class TestGitHubCopilotAgentContextProviders: async for _ in agent.run("Hello", stream=True, session=session): pass - sent_prompt = mock_session.send.call_args[0][0]["prompt"] + sent_prompt = mock_session.send.call_args[0][0] assert "History message" in sent_prompt assert "Hello" in sent_prompt diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_basic.py b/python/samples/02-agents/providers/github_copilot/github_copilot_basic.py index af59ae9b3b..93e37d89bb 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_basic.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_basic.py @@ -20,7 +20,7 @@ from typing import Annotated from agent_framework import tool from agent_framework.github import GitHubCopilotAgent from copilot.generated.session_events import PermissionRequest -from copilot.types import PermissionRequestResult +from copilot.session import PermissionRequestResult from dotenv import load_dotenv from pydantic import Field diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_file_operations.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_file_operations.py index 145702591f..1a82d9867d 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_file_operations.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_file_operations.py @@ -16,7 +16,7 @@ import asyncio from agent_framework.github import GitHubCopilotAgent from copilot.generated.session_events import PermissionRequest -from copilot.types import PermissionRequestResult +from copilot.session import PermissionRequestResult def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_mcp.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_mcp.py index 081dd1b21f..71bd67efb4 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_mcp.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_mcp.py @@ -16,7 +16,7 @@ import asyncio from agent_framework.github import GitHubCopilotAgent from copilot.generated.session_events import PermissionRequest -from copilot.types import MCPServerConfig, PermissionRequestResult +from copilot.session import MCPServerConfig, PermissionRequestResult from dotenv import load_dotenv # Load environment variables from .env file diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_multiple_permissions.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_multiple_permissions.py index 5ccc8e51f7..916061f939 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_multiple_permissions.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_multiple_permissions.py @@ -22,7 +22,7 @@ import asyncio from agent_framework.github import GitHubCopilotAgent from copilot.generated.session_events import PermissionRequest -from copilot.types import PermissionRequestResult +from copilot.session import PermissionRequestResult def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_session.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_session.py index 758eccb015..801edf52f3 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_session.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_session.py @@ -15,7 +15,7 @@ from typing import Annotated from agent_framework import tool from agent_framework.github import GitHubCopilotAgent from copilot.generated.session_events import PermissionRequest -from copilot.types import PermissionRequestResult +from copilot.session import PermissionRequestResult from pydantic import Field diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_shell.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_shell.py index 98c37d40f3..729aad6863 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_shell.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_shell.py @@ -15,7 +15,7 @@ import asyncio from agent_framework.github import GitHubCopilotAgent from copilot.generated.session_events import PermissionRequest -from copilot.types import PermissionRequestResult +from copilot.session import PermissionRequestResult def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: diff --git a/python/samples/02-agents/providers/github_copilot/github_copilot_with_url.py b/python/samples/02-agents/providers/github_copilot/github_copilot_with_url.py index 827dfd86c1..2f14648bae 100644 --- a/python/samples/02-agents/providers/github_copilot/github_copilot_with_url.py +++ b/python/samples/02-agents/providers/github_copilot/github_copilot_with_url.py @@ -15,7 +15,7 @@ import asyncio from agent_framework.github import GitHubCopilotAgent from copilot.generated.session_events import PermissionRequest -from copilot.types import PermissionRequestResult +from copilot.session import PermissionRequestResult def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult: diff --git a/python/scripts/sample_validation/create_dynamic_workflow_executor.py b/python/scripts/sample_validation/create_dynamic_workflow_executor.py index 66ba578228..f9356bbdd8 100644 --- a/python/scripts/sample_validation/create_dynamic_workflow_executor.py +++ b/python/scripts/sample_validation/create_dynamic_workflow_executor.py @@ -15,7 +15,7 @@ from agent_framework import ( ) from agent_framework.github import GitHubCopilotAgent from copilot.generated.session_events import PermissionRequest -from copilot.types import PermissionRequestResult +from copilot.session import PermissionRequestResult from pydantic import BaseModel from typing_extensions import Never diff --git a/python/uv.lock b/python/uv.lock index 3430e084e4..62616a70b5 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -526,7 +526,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=0.1.31,<0.1.33" }, + { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=0.2.1,<=0.2.1" }, ] [[package]] @@ -2290,19 +2290,19 @@ wheels = [ [[package]] name = "github-copilot-sdk" -version = "0.1.32" +version = "0.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, { name = "python-dateutil", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/67/ebd002c14fe7d2640d0fff47a0b29fdb21ed239b597afa2d2c6f6cfebb0b/github_copilot_sdk-0.1.32-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:d97bc39fbd4b51e0aea3405299da1e643838ddbf6bff284f688a2d8c20d82ff8", size = 58576987, upload-time = "2026-03-07T15:28:24.062Z" }, - { url = "https://files.pythonhosted.org/packages/a5/50/add440f61e19f5b7e6989c89c5cefcb14c23f06627621e7c3a15a1f75e5d/github_copilot_sdk-0.1.32-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8098592f34e7ee7decbcbb7615c7eb924471e65a3e4d0d93bc49b0d112f8ec51", size = 55328145, upload-time = "2026-03-07T15:28:28.395Z" }, - { url = "https://files.pythonhosted.org/packages/f9/b8/c3ca0678b21d8a0dd8fe3aa8fad4b7ec5f22cbe9d5fb3a11f82df4f40578/github_copilot_sdk-0.1.32-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:c20cae4bec3584ce007a65a363216a1f98a71428a3ca3b76622f9e556307eed2", size = 61456678, upload-time = "2026-03-07T15:28:32.646Z" }, - { url = "https://files.pythonhosted.org/packages/21/5c/bdfe177353f88d44da9600c3ec478e2b0df7a838901947b168e869ba5ad7/github_copilot_sdk-0.1.32-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:0941fd445e97a9b13fb713086c4a8c09c20ec8c7ab854cf009bd7cc213488999", size = 59641536, upload-time = "2026-03-07T15:28:36.977Z" }, - { url = "https://files.pythonhosted.org/packages/1e/d0/2f3a07c74ecd24587b8f7d26729738f73e63f3341bf4bdc9eb2bb73ddaaf/github_copilot_sdk-0.1.32-py3-none-win_amd64.whl", hash = "sha256:37a82ff0908e01512052b69df4aa498332fa5769999635425015ed43cd850622", size = 54077464, upload-time = "2026-03-07T15:28:41.34Z" }, - { url = "https://files.pythonhosted.org/packages/1c/76/292088d6ccf2daf8bcb8a94b22b4f16005a6772087896f1b43c4f0d5edaa/github_copilot_sdk-0.1.32-py3-none-win_arm64.whl", hash = "sha256:3199c99604e8d393b1d60905be80b84da44e70d16d30b92e2ae9b92814cdc4ae", size = 52083845, upload-time = "2026-03-07T15:28:45.092Z" }, + { url = "https://files.pythonhosted.org/packages/67/41/76a9d50d7600bf8d26c659dc113be62e4e56e00a5cbfd544e1b5b200f45c/github_copilot_sdk-0.2.1-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:c0823150f3b73431f04caee43d1dbafac22ae7e8bd1fc83727ee8363089ee038", size = 61076141, upload-time = "2026-04-03T20:18:22.062Z" }, + { url = "https://files.pythonhosted.org/packages/04/04/d2e8bf4587c4da270ccb9cbd5ab8a2c4b41217c2bf04a43904be8a27ae20/github_copilot_sdk-0.2.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ef7ff68eb8960515e1a2e199ac0ffb9a17cd3325266461e6edd7290e43dcf012", size = 57838464, upload-time = "2026-04-03T20:18:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/78/8b/cc8ee46724bd9fdfd6afe855a043c8403ed6884c5f3a55a9737780810396/github_copilot_sdk-0.2.1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:890f7124e3b147532a1ac6c8d5f66421ea37757b2b9990d7967f3f147a2f533a", size = 63940155, upload-time = "2026-04-03T20:18:30.297Z" }, + { url = "https://files.pythonhosted.org/packages/cf/ee/facf04e22e42d4bdd4fe3d356f3a51180a6ea769ae2ac306d0897f9bf9d9/github_copilot_sdk-0.2.1-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:6502be0b9ececacbda671835e5f61c7aaa906c6b8657ee252cad6cc8335cac8e", size = 62130538, upload-time = "2026-04-03T20:18:34.061Z" }, + { url = "https://files.pythonhosted.org/packages/3f/1c/8b105f14bf61d1d304a00ac29460cb0d4e7406ceb89907d5a7b41a72fe85/github_copilot_sdk-0.2.1-py3-none-win_amd64.whl", hash = "sha256:8275ca8e387e6b29bc5155a3c02a0eb3d035c6bc7b1896253eb0d469f2385790", size = 56547331, upload-time = "2026-04-03T20:18:37.859Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c1/0ce319d2f618e9bc89f275e60b1920f4587eb0218bba6cbb84283dc7a7f3/github_copilot_sdk-0.2.1-py3-none-win_arm64.whl", hash = "sha256:1f9b59b7c41f31be416bf20818f58e25b6adc76f6d17357653fde6fbab662606", size = 54499549, upload-time = "2026-04-03T20:18:41.77Z" }, ] [[package]] From 4a36f10888e8dac83832a572de3c8008b502cba4 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:23:21 +0900 Subject: [PATCH 14/22] Python: Bump Python version to 1.0.1 for a release (#5196) * Bump Python version to 1.1.0 for a release * Fix changelog * 1.0.1 instead of 1.1.0 * Update CHANGELOG.md * update version and changelog * Bump lower bounds --- python/CHANGELOG.md | 28 +- python/packages/a2a/pyproject.toml | 4 +- python/packages/ag-ui/pyproject.toml | 4 +- python/packages/anthropic/pyproject.toml | 4 +- .../packages/azure-ai-search/pyproject.toml | 4 +- python/packages/azure-cosmos/pyproject.toml | 4 +- python/packages/azurefunctions/pyproject.toml | 4 +- python/packages/bedrock/pyproject.toml | 4 +- python/packages/chatkit/pyproject.toml | 4 +- python/packages/claude/pyproject.toml | 4 +- python/packages/copilotstudio/pyproject.toml | 4 +- python/packages/core/pyproject.toml | 2 +- python/packages/declarative/pyproject.toml | 4 +- python/packages/devui/pyproject.toml | 4 +- python/packages/durabletask/pyproject.toml | 4 +- python/packages/foundry/pyproject.toml | 6 +- python/packages/foundry_local/pyproject.toml | 6 +- python/packages/github_copilot/pyproject.toml | 4 +- python/packages/lab/pyproject.toml | 4 +- python/packages/mem0/pyproject.toml | 4 +- python/packages/ollama/pyproject.toml | 4 +- python/packages/openai/pyproject.toml | 4 +- python/packages/orchestrations/pyproject.toml | 4 +- python/packages/purview/pyproject.toml | 4 +- python/packages/redis/pyproject.toml | 4 +- python/pyproject.toml | 4 +- python/pytest.xml | 1 - python/python-coverage.xml | 28040 ---------------- python/uv.lock | 50 +- 29 files changed, 103 insertions(+), 28118 deletions(-) delete mode 100644 python/pytest.xml delete mode 100644 python/python-coverage.xml diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 6f51601910..99947710c9 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.1] - 2026-04-09 + +### Added +- **samples**: Add sample documentation for two separate Neo4j context providers for retrieval and memory ([#4010](https://github.com/microsoft/agent-framework/pull/4010)) +- **agent-framework-azure-cosmos**: Add Cosmos DB NoSQL checkpoint storage for Python workflows ([#4916](https://github.com/microsoft/agent-framework/pull/4916)) + +### Changed +- **docs**: Remove pre-release flag from agent-framework installation instructions ([#5082](https://github.com/microsoft/agent-framework/pull/5082)) +- **samples**: Revise agent examples in `README.md` ([#5067](https://github.com/microsoft/agent-framework/pull/5067)) +- **repo**: Update `CHANGELOG` with v1.0.0 release ([#5069](https://github.com/microsoft/agent-framework/pull/5069)) +- **agent-framework-orchestrations**: [BREAKING] Fix handoff workflow context management and improve AG-UI demo ([#5136](https://github.com/microsoft/agent-framework/pull/5136)) +- **agent-framework-core**: Restrict persisted checkpoint deserialization by default ([#4941](https://github.com/microsoft/agent-framework/pull/4941)) +- **samples**: Bump `vite` from 7.3.1 to 7.3.2 in `/python/samples/05-end-to-end/ag_ui_workflow_handoff/frontend` ([#5132](https://github.com/microsoft/agent-framework/pull/5132)) +- **python**: Bump `cryptography` from 46.0.6 to 46.0.7 ([#5176](https://github.com/microsoft/agent-framework/pull/5176)) +- **python**: Bump `mcp` from 1.26.0 to 1.27.0 ([#5117](https://github.com/microsoft/agent-framework/pull/5117)) +- **python**: Bump `mcp[ws]` from 1.26.0 to 1.27.0 ([#5119](https://github.com/microsoft/agent-framework/pull/5119)) + +### Fixed +- **agent-framework-core**: Raise clear handler registration error for unresolved `TypeVar` annotations ([#4944](https://github.com/microsoft/agent-framework/pull/4944)) +- **agent-framework-openai**: Fix `response_format` crash on background polling with empty text ([#5146](https://github.com/microsoft/agent-framework/pull/5146)) +- **agent-framework-foundry**: Strip tools from `FoundryAgent` request when `agent_reference` is present ([#5101](https://github.com/microsoft/agent-framework/pull/5101)) +- **agent-framework-core**: Fix test compatibility for entity key validation ([#5179](https://github.com/microsoft/agent-framework/pull/5179)) +- **agent-framework-openai**: Stop emitting duplicate reasoning content from `response.reasoning_text.done` and `response.reasoning_summary_text.done` events ([#5162](https://github.com/microsoft/agent-framework/pull/5162)) + + ## [1.0.0] - 2026-04-02 ### Added @@ -870,7 +895,8 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai** For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/). -[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0...HEAD +[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.1...HEAD +[1.0.1]: https://github.com/microsoft/agent-framework/compare/python-1.0.0...python-1.0.1 [1.0.0]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc6...python-1.0.0 [1.0.0rc6]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc5...python-1.0.0rc6 [1.0.0rc5]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc4...python-1.0.0rc5 diff --git a/python/packages/a2a/pyproject.toml b/python/packages/a2a/pyproject.toml index 4171f72c4c..787016a287 100644 --- a/python/packages/a2a/pyproject.toml +++ b/python/packages/a2a/pyproject.toml @@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260402" +version = "1.0.0b260409" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", "a2a-sdk>=0.3.5,<0.3.24", ] diff --git a/python/packages/ag-ui/pyproject.toml b/python/packages/ag-ui/pyproject.toml index 36f9bb805f..abe17daea9 100644 --- a/python/packages/ag-ui/pyproject.toml +++ b/python/packages/ag-ui/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agent-framework-ag-ui" -version = "1.0.0b260402" +version = "1.0.0b260409" description = "AG-UI protocol integration for Agent Framework" readme = "README.md" license-files = ["LICENSE"] @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", "ag-ui-protocol==0.1.13", "fastapi>=0.115.0,<0.133.1", "uvicorn[standard]>=0.30.0,<0.42.0" diff --git a/python/packages/anthropic/pyproject.toml b/python/packages/anthropic/pyproject.toml index feed99a503..f162cc5d06 100644 --- a/python/packages/anthropic/pyproject.toml +++ b/python/packages/anthropic/pyproject.toml @@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260402" +version = "1.0.0b260409" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", "anthropic>=0.80.0,<0.80.1", ] diff --git a/python/packages/azure-ai-search/pyproject.toml b/python/packages/azure-ai-search/pyproject.toml index d6fe64ba36..15cc466b20 100644 --- a/python/packages/azure-ai-search/pyproject.toml +++ b/python/packages/azure-ai-search/pyproject.toml @@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260402" +version = "1.0.0b260409" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", "azure-search-documents>=11.7.0b2,<11.7.0b3", ] diff --git a/python/packages/azure-cosmos/pyproject.toml b/python/packages/azure-cosmos/pyproject.toml index 1dc10cdf4a..4193b07014 100644 --- a/python/packages/azure-cosmos/pyproject.toml +++ b/python/packages/azure-cosmos/pyproject.toml @@ -4,7 +4,7 @@ description = "Azure Cosmos DB history provider integration for Microsoft Agent authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260402" +version = "1.0.0b260409" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", "azure-cosmos>=4.3.0,<5", ] diff --git a/python/packages/azurefunctions/pyproject.toml b/python/packages/azurefunctions/pyproject.toml index 9d92e51d09..8445c08ed9 100644 --- a/python/packages/azurefunctions/pyproject.toml +++ b/python/packages/azurefunctions/pyproject.toml @@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260402" +version = "1.0.0b260409" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", "agent-framework-durabletask", "azure-functions>=1.24.0,<2", "azure-functions-durable>=1.3.1,<2", diff --git a/python/packages/bedrock/pyproject.toml b/python/packages/bedrock/pyproject.toml index ac81bc66d2..66996ad9ba 100644 --- a/python/packages/bedrock/pyproject.toml +++ b/python/packages/bedrock/pyproject.toml @@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260402" +version = "1.0.0b260409" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", "boto3>=1.35.0,<2.0.0", "botocore>=1.35.0,<2.0.0", ] diff --git a/python/packages/chatkit/pyproject.toml b/python/packages/chatkit/pyproject.toml index 2cdce32a0d..ae0a795e0d 100644 --- a/python/packages/chatkit/pyproject.toml +++ b/python/packages/chatkit/pyproject.toml @@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260402" +version = "1.0.0b260409" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", "openai-chatkit>=1.4.1,<2.0.0", ] diff --git a/python/packages/claude/pyproject.toml b/python/packages/claude/pyproject.toml index 7740d553a3..4b2e6059fc 100644 --- a/python/packages/claude/pyproject.toml +++ b/python/packages/claude/pyproject.toml @@ -4,7 +4,7 @@ description = "Claude Agent SDK integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260402" +version = "1.0.0b260409" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", "claude-agent-sdk>=0.1.36,<0.1.49", ] diff --git a/python/packages/copilotstudio/pyproject.toml b/python/packages/copilotstudio/pyproject.toml index c9f6ac7b01..90bb6b107f 100644 --- a/python/packages/copilotstudio/pyproject.toml +++ b/python/packages/copilotstudio/pyproject.toml @@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260402" +version = "1.0.0b260409" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", "microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2", ] diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index e4aa1f5e40..e26e1be6c9 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0" +version = "1.0.1" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/declarative/pyproject.toml b/python/packages/declarative/pyproject.toml index c502409de6..9018b2676b 100644 --- a/python/packages/declarative/pyproject.toml +++ b/python/packages/declarative/pyproject.toml @@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260402" +version = "1.0.0b260409" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", "powerfx>=0.0.32,<0.0.35; python_version < '3.14'", "pyyaml>=6.0,<7.0", ] diff --git a/python/packages/devui/pyproject.toml b/python/packages/devui/pyproject.toml index fd3bc62fe2..b12fbef49b 100644 --- a/python/packages/devui/pyproject.toml +++ b/python/packages/devui/pyproject.toml @@ -4,7 +4,7 @@ description = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260402" +version = "1.0.0b260409" license-files = ["LICENSE"] urls.homepage = "https://github.com/microsoft/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", "openai>=1.99.0,<3", "opentelemetry-sdk>=1.39.0,<2", "fastapi>=0.115.0,<0.133.1", diff --git a/python/packages/durabletask/pyproject.toml b/python/packages/durabletask/pyproject.toml index a7c325442c..8e95b3920f 100644 --- a/python/packages/durabletask/pyproject.toml +++ b/python/packages/durabletask/pyproject.toml @@ -4,7 +4,7 @@ description = "Durable Task integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260402" +version = "1.0.0b260409" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", "durabletask>=1.3.0,<2", "durabletask-azuremanaged>=1.3.0,<2", "python-dateutil>=2.8.0,<3", diff --git a/python/packages/foundry/pyproject.toml b/python/packages/foundry/pyproject.toml index 335d7ef394..69d58ee3e5 100644 --- a/python/packages/foundry/pyproject.toml +++ b/python/packages/foundry/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Foundry integrations for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0" +version = "1.0.1" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,8 +23,8 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", - "agent-framework-openai>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", + "agent-framework-openai>=1.0.1,<2", "azure-ai-inference>=1.0.0b9,<1.0.0b10", "azure-ai-projects>=2.0.0,<3.0", ] diff --git a/python/packages/foundry_local/pyproject.toml b/python/packages/foundry_local/pyproject.toml index 1ee093516a..3002cd0d69 100644 --- a/python/packages/foundry_local/pyproject.toml +++ b/python/packages/foundry_local/pyproject.toml @@ -4,7 +4,7 @@ description = "Foundry Local integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260402" +version = "1.0.0b260409" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,8 +23,8 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", - "agent-framework-openai>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", + "agent-framework-openai>=1.0.1,<2", "foundry-local-sdk>=0.5.1,<0.5.2", ] diff --git a/python/packages/github_copilot/pyproject.toml b/python/packages/github_copilot/pyproject.toml index 2a71d577af..90766cb7f6 100644 --- a/python/packages/github_copilot/pyproject.toml +++ b/python/packages/github_copilot/pyproject.toml @@ -4,7 +4,7 @@ description = "GitHub Copilot integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260402" +version = "1.0.0b260409" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", "github-copilot-sdk>=0.2.1,<=0.2.1; python_version >= '3.11'", ] diff --git a/python/packages/lab/pyproject.toml b/python/packages/lab/pyproject.toml index d7a15aac4a..10e98810d5 100644 --- a/python/packages/lab/pyproject.toml +++ b/python/packages/lab/pyproject.toml @@ -4,7 +4,7 @@ description = "Experimental modules for Microsoft Agent Framework" authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260402" +version = "1.0.0b260409" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", ] [project.optional-dependencies] diff --git a/python/packages/mem0/pyproject.toml b/python/packages/mem0/pyproject.toml index 7995afada6..20c8a6d467 100644 --- a/python/packages/mem0/pyproject.toml +++ b/python/packages/mem0/pyproject.toml @@ -4,7 +4,7 @@ description = "Mem0 integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260402" +version = "1.0.0b260409" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", "mem0ai>=1.0.0,<2", ] diff --git a/python/packages/ollama/pyproject.toml b/python/packages/ollama/pyproject.toml index dd18f2d14b..bf2b93cfaa 100644 --- a/python/packages/ollama/pyproject.toml +++ b/python/packages/ollama/pyproject.toml @@ -4,7 +4,7 @@ description = "Ollama integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260402" +version = "1.0.0b260409" license-files = ["LICENSE"] urls.homepage = "https://learn.microsoft.com/en-us/agent-framework/" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", "ollama>=0.5.3,<0.5.4", ] diff --git a/python/packages/openai/pyproject.toml b/python/packages/openai/pyproject.toml index 2e2cef12ad..22b59e5a43 100644 --- a/python/packages/openai/pyproject.toml +++ b/python/packages/openai/pyproject.toml @@ -4,7 +4,7 @@ description = "OpenAI integrations for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0" +version = "1.0.1" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", "openai>=1.99.0,<3", ] diff --git a/python/packages/orchestrations/pyproject.toml b/python/packages/orchestrations/pyproject.toml index 69fb60e4bb..9d2232fb4a 100644 --- a/python/packages/orchestrations/pyproject.toml +++ b/python/packages/orchestrations/pyproject.toml @@ -4,7 +4,7 @@ description = "Orchestration patterns for Microsoft Agent Framework. Includes Se authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260402" +version = "1.0.0b260409" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", ] [tool.uv] diff --git a/python/packages/purview/pyproject.toml b/python/packages/purview/pyproject.toml index ad63be4448..711e0cbaf6 100644 --- a/python/packages/purview/pyproject.toml +++ b/python/packages/purview/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Purview (Graph dataSecurityAndGovernance) integration f authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260402" +version = "1.0.0b260409" license-files = ["LICENSE"] urls.homepage = "https://github.com/microsoft/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -24,7 +24,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", "azure-core>=1.30.0,<2", "httpx>=0.27.0,<0.29", ] diff --git a/python/packages/redis/pyproject.toml b/python/packages/redis/pyproject.toml index 0feec5bd23..533ed6635d 100644 --- a/python/packages/redis/pyproject.toml +++ b/python/packages/redis/pyproject.toml @@ -4,7 +4,7 @@ description = "Redis integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260402" +version = "1.0.0b260409" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0,<2", + "agent-framework-core>=1.0.1,<2", "redis>=6.4.0,<7.2.1", "redisvl>=0.11.0,<0.16", "numpy>=2.2.6,<3" diff --git a/python/pyproject.toml b/python/pyproject.toml index 06391b8b59..92b37b57c7 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0" +version = "1.0.1" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core[all]==1.0.0", + "agent-framework-core[all]==1.0.1", ] [dependency-groups] diff --git a/python/pytest.xml b/python/pytest.xml deleted file mode 100644 index 13d4791f5a..0000000000 --- a/python/pytest.xml +++ /dev/null @@ -1 +0,0 @@ -/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/core/test_observability.py:592: Skipping OTLP exporter tests - optional dependency not installed by default/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/core/test_observability.py:609: Skipping OTLP exporter tests - optional dependency not installed by default/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/core/test_observability.py:626: Skipping OTLP exporter tests - optional dependency not installed by default/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/core/test_observability.py:645: Skipping OTLP exporter tests - optional dependency not installed by default/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/core/test_observability.py:663: Skipping OTLP exporter tests - optional dependency not installed by default/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/core/test_observability.py:681: Skipping OTLP exporter tests - optional dependency not installed by default/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/core/test_observability.py:704: Skipping OTLP exporter tests - optional dependency not installed by default/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/core/test_observability.py:776: Skipping OTLP exporter tests - optional dependency not installed by default/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/core/test_observability.py:790: Skipping OTLP exporter tests - optional dependency not installed by default/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/core/test_observability.py:804: Skipping OTLP exporter tests - optional dependency not installed by default/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/core/test_observability.py:823: Skipping OTLP exporter tests - optional dependency not installed by default/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/core/test_observability.py:839: Skipping OTLP exporter tests - optional dependency not installed by default/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/core/test_observability.py:861: Skipping OTLP exporter tests - optional dependency not installed by default/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/core/test_observability.py:888: Skipping OTLP exporter tests - optional dependency not installed by default/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/core/test_function_invocation_logic.py:1463: Error handling and failsafe behavior needs investigation in unified API/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/core/test_function_invocation_logic.py:2885: Failsafe behavior needs investigation in unified API/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/declarative/tests/test_powerfx_yaml_compatibility.py:521: No non-English LC_NUMERIC locale available on this system/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/workflow/test_viz.py:160: Requires graphviz to be installed/repos/agent-framework/.worktrees/agent/fix-4894-1/python/packages/core/tests/workflow/test_viz.py:195: could not import 'graphviz': No module named 'graphviz' \ No newline at end of file diff --git a/python/python-coverage.xml b/python/python-coverage.xml deleted file mode 100644 index 15d32ded01..0000000000 --- a/python/python-coverage.xml +++ /dev/null @@ -1,28040 +0,0 @@ - - - - - - /repos/agent-framework/.worktrees/agent/fix-4894-1/python - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/python/uv.lock b/python/uv.lock index 62616a70b5..c22522d898 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -93,7 +93,7 @@ wheels = [ [[package]] name = "agent-framework" -version = "1.0.0" +version = "1.0.1" source = { virtual = "." } dependencies = [ { name = "agent-framework-core", extra = ["all"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -146,7 +146,7 @@ dev = [ [[package]] name = "agent-framework-a2a" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/a2a" } dependencies = [ { name = "a2a-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -161,7 +161,7 @@ requires-dist = [ [[package]] name = "agent-framework-ag-ui" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/ag-ui" } dependencies = [ { name = "ag-ui-protocol", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -189,7 +189,7 @@ provides-extras = ["dev"] [[package]] name = "agent-framework-anthropic" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/anthropic" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -204,7 +204,7 @@ requires-dist = [ [[package]] name = "agent-framework-azure-ai-search" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/azure-ai-search" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -219,7 +219,7 @@ requires-dist = [ [[package]] name = "agent-framework-azure-cosmos" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/azure-cosmos" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -234,7 +234,7 @@ requires-dist = [ [[package]] name = "agent-framework-azurefunctions" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/azurefunctions" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -256,7 +256,7 @@ dev = [] [[package]] name = "agent-framework-bedrock" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/bedrock" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -273,7 +273,7 @@ requires-dist = [ [[package]] name = "agent-framework-chatkit" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/chatkit" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -288,7 +288,7 @@ requires-dist = [ [[package]] name = "agent-framework-claude" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/claude" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -303,7 +303,7 @@ requires-dist = [ [[package]] name = "agent-framework-copilotstudio" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/copilotstudio" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -318,7 +318,7 @@ requires-dist = [ [[package]] name = "agent-framework-core" -version = "1.0.0" +version = "1.0.1" source = { editable = "packages/core" } dependencies = [ { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -390,7 +390,7 @@ provides-extras = ["all"] [[package]] name = "agent-framework-declarative" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/declarative" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -415,7 +415,7 @@ dev = [{ name = "types-pyyaml", specifier = "==6.0.12.20250915" }] [[package]] name = "agent-framework-devui" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/devui" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -453,7 +453,7 @@ provides-extras = ["dev", "all"] [[package]] name = "agent-framework-durabletask" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/durabletask" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -480,7 +480,7 @@ dev = [{ name = "types-python-dateutil", specifier = "==2.9.0.20260402" }] [[package]] name = "agent-framework-foundry" -version = "1.0.0" +version = "1.0.1" source = { editable = "packages/foundry" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -499,7 +499,7 @@ requires-dist = [ [[package]] name = "agent-framework-foundry-local" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/foundry_local" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -516,7 +516,7 @@ requires-dist = [ [[package]] name = "agent-framework-github-copilot" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/github_copilot" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -531,7 +531,7 @@ requires-dist = [ [[package]] name = "agent-framework-lab" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/lab" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -612,7 +612,7 @@ dev = [ [[package]] name = "agent-framework-mem0" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/mem0" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -627,7 +627,7 @@ requires-dist = [ [[package]] name = "agent-framework-ollama" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/ollama" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -642,7 +642,7 @@ requires-dist = [ [[package]] name = "agent-framework-openai" -version = "1.0.0" +version = "1.0.1" source = { editable = "packages/openai" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -657,7 +657,7 @@ requires-dist = [ [[package]] name = "agent-framework-orchestrations" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/orchestrations" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -668,7 +668,7 @@ requires-dist = [{ name = "agent-framework-core", editable = "packages/core" }] [[package]] name = "agent-framework-purview" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/purview" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -685,7 +685,7 @@ requires-dist = [ [[package]] name = "agent-framework-redis" -version = "1.0.0b260402" +version = "1.0.0b260409" source = { editable = "packages/redis" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, From e5f7b9c260961916e108ca10780988aeefd51662 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:56:28 +0100 Subject: [PATCH 15/22] .NET: Support reflection for discovery of resources and scripts in class-based skills (#5183) * support reflection for discovery of resources and scripts in class-based skills * fix format issues * refactor samples to use reflection * Validate resource member signatures during discovery Add discovery-time validation in AgentClassSkill.DiscoverResources() to fail fast when [AgentSkillResource] is applied to members with incompatible signatures: - Reject indexer properties (getter has parameters) - Reject methods with parameters other than IServiceProvider or CancellationToken Throws InvalidOperationException with actionable error messages instead of allowing silent runtime failures when ReadAsync invokes the AIFunction with no named arguments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * prevent duplicates --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Agent_Step03_ClassBasedSkills.csproj | 2 +- .../Agent_Step03_ClassBasedSkills/Program.cs | 69 +- .../Agent_Step03_ClassBasedSkills/README.md | 8 +- .../Agent_Step04_MixedSkills.csproj | 2 +- .../Agent_Step04_MixedSkills/Program.cs | 55 +- .../Agent_Step05_SkillsWithDI.csproj | 2 +- .../Agent_Step05_SkillsWithDI/Program.cs | 54 +- .../Skills/AgentSkillsProviderBuilder.cs | 2 +- .../Skills/Programmatic/AgentClassSkill.cs | 254 +++- .../Programmatic/AgentInlineSkillResource.cs | 23 + .../Programmatic/AgentInlineSkillScript.cs | 22 + .../AgentSkillResourceAttribute.cs | 73 ++ .../Programmatic/AgentSkillScriptAttribute.cs | 72 ++ .../AgentSkills/AgentClassSkillTests.cs | 1034 +++++++++++++---- .../AgentInlineSkillResourceTests.cs | 55 + .../AgentInlineSkillScriptTests.cs | 74 ++ .../AgentSkillResourceAttributeTests.cs | 29 + .../AgentSkillScriptAttributeTests.cs | 29 + .../AgentSkills/AgentSkillsProviderTests.cs | 4 +- 19 files changed, 1536 insertions(+), 327 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentSkillResourceAttribute.cs create mode 100644 dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentSkillScriptAttribute.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillResourceAttributeTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillScriptAttributeTests.cs diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Agent_Step03_ClassBasedSkills.csproj b/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Agent_Step03_ClassBasedSkills.csproj index fd3d71fe7e..d7233702ac 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Agent_Step03_ClassBasedSkills.csproj +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Agent_Step03_ClassBasedSkills.csproj @@ -6,7 +6,7 @@ enable enable - $(NoWarn);MAAI001 + $(NoWarn);MAAI001;IDE0051 diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Program.cs index 8be991598a..7f5e356a60 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Program.cs +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Program.cs @@ -1,8 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample demonstrates how to define Agent Skills as C# classes using AgentClassSkill. -// Class-based skills bundle all components into a single class implementation. +// This sample demonstrates how to define Agent Skills as C# classes using AgentClassSkill +// with attributes for automatic script and resource discovery. +using System.ComponentModel; using System.Text.Json; using Azure.AI.OpenAI; using Azure.Identity; @@ -44,17 +45,16 @@ AgentResponse response = await agent.RunAsync( Console.WriteLine($"Agent: {response.Text}"); /// -/// A unit-converter skill defined as a C# class. +/// A unit-converter skill defined as a C# class using attributes for discovery. /// /// -/// Class-based skills bundle all components (name, description, body, resources, scripts) -/// into a single class. +/// Properties annotated with are automatically +/// discovered as skill resources, and methods annotated with +/// are automatically discovered as skill scripts. Alternatively, +/// and can be overridden. /// -internal sealed class UnitConverterSkill : AgentClassSkill +internal sealed class UnitConverterSkill : AgentClassSkill { - private IReadOnlyList? _resources; - private IReadOnlyList? _scripts; - /// public override AgentSkillFrontmatter Frontmatter { get; } = new( "unit-converter", @@ -69,31 +69,40 @@ internal sealed class UnitConverterSkill : AgentClassSkill 3. Present the result clearly with both units. """; - /// - public override IReadOnlyList? Resources => this._resources ??= - [ - CreateResource( - "conversion-table", - """ - # Conversion Tables + /// + /// Gets the used to marshal parameters and return values + /// for scripts and resources. + /// + /// + /// This override is not necessary for this sample, but can be used to provide custom + /// serialization options, for example a source-generated JsonTypeInfoResolver + /// for Native AOT compatibility. + /// + protected override JsonSerializerOptions? SerializerOptions => null; - Formula: **result = value × factor** + /// + /// A conversion table resource providing multiplication factors. + /// + [AgentSkillResource("conversion-table")] + [Description("Lookup table of multiplication factors for common unit conversions.")] + public string ConversionTable => """ + # Conversion Tables - | From | To | Factor | - |-------------|-------------|----------| - | miles | kilometers | 1.60934 | - | kilometers | miles | 0.621371 | - | pounds | kilograms | 0.453592 | - | kilograms | pounds | 2.20462 | - """), - ]; + Formula: **result = value × factor** - /// - public override IReadOnlyList? Scripts => this._scripts ??= - [ - CreateScript("convert", ConvertUnits), - ]; + | From | To | Factor | + |-------------|-------------|----------| + | miles | kilometers | 1.60934 | + | kilometers | miles | 0.621371 | + | pounds | kilograms | 0.453592 | + | kilograms | pounds | 2.20462 | + """; + /// + /// Converts a value by the given factor. + /// + [AgentSkillScript("convert")] + [Description("Multiplies a value by a conversion factor and returns the result as JSON.")] private static string ConvertUnits(double value, double factor) { double result = Math.Round(value * factor, 4); diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/README.md b/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/README.md index 3525bb7a98..028cb05a37 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/README.md +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/README.md @@ -1,12 +1,16 @@ # Class-Based Agent Skills Sample -This sample demonstrates how to define **Agent Skills as C# classes** using `AgentClassSkill`. +This sample demonstrates how to define **Agent Skills as C# classes** using `AgentClassSkill` +with **attributes** for automatic script and resource discovery. ## What it demonstrates - Creating skills as classes that extend `AgentClassSkill` -- Bundling name, description, body, resources, and scripts into a single class +- Using `[AgentSkillResource]` on properties to define resources +- Using `[AgentSkillScript]` on methods to define scripts +- Automatic discovery (no need to override `Resources`/`Scripts`) - Using the `AgentSkillsProvider` constructor with class-based skills +- Overriding `SerializerOptions` for Native AOT compatibility ## Skills Included diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Agent_Step04_MixedSkills.csproj b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Agent_Step04_MixedSkills.csproj index 7e7e9ef0fa..01abf37da8 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Agent_Step04_MixedSkills.csproj +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Agent_Step04_MixedSkills.csproj @@ -6,7 +6,7 @@ enable enable - $(NoWarn);MAAI001 + $(NoWarn);MAAI001;IDE0051 diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Program.cs index ab5da71a3c..28d5cb9ee9 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Program.cs +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Program.cs @@ -8,11 +8,12 @@ // Three different skill sources are registered here: // 1. File-based: unit-converter (miles↔km, pounds↔kg) from SKILL.md on disk // 2. Code-defined: volume-converter (gallons↔liters) using AgentInlineSkill -// 3. Class-based: temperature-converter (°F↔°C↔K) using AgentClassSkill +// 3. Class-based: temperature-converter (°F↔°C↔K) using AgentClassSkill with attributes // // For simpler, single-source scenarios, see the earlier steps in this sample series // (e.g., Step01 for file-based, Step02 for code-defined, Step03 for class-based). +using System.ComponentModel; using System.Text.Json; using Azure.AI.OpenAI; using Azure.Identity; @@ -89,13 +90,15 @@ AgentResponse response = await agent.RunAsync( Console.WriteLine($"Agent: {response.Text}"); /// -/// A temperature-converter skill defined as a C# class. +/// A temperature-converter skill defined as a C# class using attributes for discovery. /// -internal sealed class TemperatureConverterSkill : AgentClassSkill +/// +/// Properties annotated with are automatically +/// discovered as skill resources, and methods annotated with +/// are automatically discovered as skill scripts. +/// +internal sealed class TemperatureConverterSkill : AgentClassSkill { - private IReadOnlyList? _resources; - private IReadOnlyList? _scripts; - /// public override AgentSkillFrontmatter Frontmatter { get; } = new( "temperature-converter", @@ -110,29 +113,27 @@ internal sealed class TemperatureConverterSkill : AgentClassSkill 3. Present the result clearly with both temperature scales. """; - /// - public override IReadOnlyList? Resources => this._resources ??= - [ - CreateResource( - "temperature-conversion-formulas", - """ - # Temperature Conversion Formulas + /// + /// A reference table of temperature conversion formulas. + /// + [AgentSkillResource("temperature-conversion-formulas")] + [Description("Formulas for converting between Fahrenheit, Celsius, and Kelvin.")] + public string ConversionFormulas => """ + # Temperature Conversion Formulas - | From | To | Formula | - |-------------|-------------|---------------------------| - | Fahrenheit | Celsius | °C = (°F − 32) × 5/9 | - | Celsius | Fahrenheit | °F = (°C × 9/5) + 32 | - | Celsius | Kelvin | K = °C + 273.15 | - | Kelvin | Celsius | °C = K − 273.15 | - """), - ]; - - /// - public override IReadOnlyList? Scripts => this._scripts ??= - [ - CreateScript("convert-temperature", ConvertTemperature), - ]; + | From | To | Formula | + |-------------|-------------|---------------------------| + | Fahrenheit | Celsius | °C = (°F − 32) × 5/9 | + | Celsius | Fahrenheit | °F = (°C × 9/5) + 32 | + | Celsius | Kelvin | K = °C + 273.15 | + | Kelvin | Celsius | °C = K − 273.15 | + """; + /// + /// Converts a temperature value between scales. + /// + [AgentSkillScript("convert-temperature")] + [Description("Converts a temperature value from one scale to another.")] private static string ConvertTemperature(double value, string from, string to) { double result = (from.ToUpperInvariant(), to.ToUpperInvariant()) switch diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Agent_Step05_SkillsWithDI.csproj b/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Agent_Step05_SkillsWithDI.csproj index 959fa29167..699672ded5 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Agent_Step05_SkillsWithDI.csproj +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Agent_Step05_SkillsWithDI.csproj @@ -6,7 +6,7 @@ enable enable - $(NoWarn);MAAI001;CA1812 + $(NoWarn);MAAI001;CA1812;IDE0051 diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Program.cs index 50b0545be3..251503a918 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Program.cs +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Program.cs @@ -13,6 +13,7 @@ // showing that DI works identically regardless of how the skill is defined. // When prompted with a question spanning both domains, the agent uses both skills. +using System.ComponentModel; using System.Text.Json; using Azure.AI.OpenAI; using Azure.Identity; @@ -62,8 +63,8 @@ var distanceSkill = new AgentInlineSkill( // Approach 2: Class-Based Skill with DI (AgentClassSkill) // ===================================================================== // Handles weight conversions (pounds ↔ kilograms). -// Resources and scripts are encapsulated in a class. Factory methods -// CreateResource and CreateScript accept delegates with IServiceProvider. +// Resources and scripts are discovered via reflection using attributes. +// Methods with an IServiceProvider parameter receive DI automatically. // // Alternatively, class-based skills can accept dependencies through their // constructor. Register the skill class itself in the ServiceCollection and @@ -113,14 +114,13 @@ Console.WriteLine($"Agent: {response.Text}"); /// /// /// This skill resolves from the DI container -/// in both its resource and script functions. This enables clean separation of -/// concerns and testability while retaining the class-based skill pattern. +/// in both its resource and script methods. Methods with an +/// parameter are automatically injected by the framework. Properties and methods annotated +/// with and +/// are automatically discovered via reflection. /// -internal sealed class WeightConverterSkill : AgentClassSkill +internal sealed class WeightConverterSkill : AgentClassSkill { - private IReadOnlyList? _resources; - private IReadOnlyList? _scripts; - /// public override AgentSkillFrontmatter Frontmatter { get; } = new( "weight-converter", @@ -135,25 +135,27 @@ internal sealed class WeightConverterSkill : AgentClassSkill 3. Present the result clearly with both units. """; - /// - public override IReadOnlyList? Resources => this._resources ??= - [ - CreateResource("weight-table", (IServiceProvider serviceProvider) => - { - var service = serviceProvider.GetRequiredService(); - return service.GetWeightTable(); - }), - ]; + /// + /// Returns the weight conversion table from the DI-registered . + /// + [AgentSkillResource("weight-table")] + [Description("Lookup table of multiplication factors for weight conversions.")] + private static string GetWeightTable(IServiceProvider serviceProvider) + { + var service = serviceProvider.GetRequiredService(); + return service.GetWeightTable(); + } - /// - public override IReadOnlyList? Scripts => this._scripts ??= - [ - CreateScript("convert", (double value, double factor, IServiceProvider serviceProvider) => - { - var service = serviceProvider.GetRequiredService(); - return service.Convert(value, factor); - }), - ]; + /// + /// Converts a value by the given factor using the DI-registered . + /// + [AgentSkillScript("convert")] + [Description("Multiplies a value by a conversion factor and returns the result as JSON.")] + private static string Convert(double value, double factor, IServiceProvider serviceProvider) + { + var service = serviceProvider.GetRequiredService(); + return service.Convert(value, factor); + } } // --------------------------------------------------------------------------- diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs index 0da54d0426..e49c620187 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/AgentSkillsProviderBuilder.cs @@ -21,7 +21,7 @@ namespace Microsoft.Agents.AI; /// /// /// Mixed skill types — combine file-based, code-defined (), -/// and class-based () skills in a single provider. +/// and class-based () skills in a single provider. /// Multiple file script runners — use different script runners for different /// file skill directories via per-source scriptRunner parameters on /// / . diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs index 4febb8bc79..b44f423bc2 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentClassSkill.cs @@ -1,8 +1,12 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Generic; +using System.ComponentModel; using System.Diagnostics.CodeAnalysis; +using System.Reflection; using System.Text.Json; +using System.Threading; using Microsoft.Extensions.AI; using Microsoft.Shared.DiagnosticIds; @@ -11,17 +15,55 @@ namespace Microsoft.Agents.AI; /// /// Abstract base class for defining skills as C# classes that bundle all components together. /// +/// +/// The concrete skill type. This type parameter is annotated with +/// to ensure that the IL trimmer and Native AOT compiler +/// preserve the members needed for attribute-based discovery. +/// /// /// /// Inherit from this class to create a self-contained skill definition. Override the abstract -/// properties to provide name, description, and instructions. Use , -/// , and to define -/// inline resources and scripts. +/// properties to provide name, description, and instructions. +/// +/// +/// Scripts and resources can be defined in two ways: +/// +/// +/// Attribute-based (recommended): Annotate methods with to define scripts, +/// and properties or methods with to define resources. These are automatically +/// discovered via reflection on . This approach is compatible with Native AOT. +/// +/// +/// Explicit override: Override and , using +/// , , +/// and to define inline resources and scripts. This approach is also compatible with Native AOT. +/// +/// +/// +/// +/// Multi-level inheritance limitation: Discovery reflects only on , +/// so if a further-derived subclass adds new attributed members, they will not be discovered unless +/// that subclass also uses the CRTP pattern +/// (e.g., class SpecialSkill : AgentClassSkill<SpecialSkill>). /// /// /// /// -/// public class PdfFormatterSkill : AgentClassSkill +/// // Attribute-based approach (recommended, AOT-compatible): +/// public class PdfFormatterSkill : AgentClassSkill<PdfFormatterSkill> +/// { +/// public override AgentSkillFrontmatter Frontmatter { get; } = new("pdf-formatter", "Format documents as PDF."); +/// protected override string Instructions => "Use this skill to format documents..."; +/// +/// [AgentSkillResource("template")] +/// public string Template => "Use this template..."; +/// +/// [AgentSkillScript("format-pdf")] +/// private static string FormatPdf(string content) => content; +/// } +/// +/// // Explicit override approach (AOT-compatible): +/// public class ExplicitPdfFormatterSkill : AgentClassSkill<ExplicitPdfFormatterSkill> /// { /// private IReadOnlyList<AgentSkillResource>? _resources; /// private IReadOnlyList<AgentSkillScript>? _scripts; @@ -44,15 +86,41 @@ namespace Microsoft.Agents.AI; /// /// [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] -public abstract class AgentClassSkill : AgentSkill +public abstract class AgentClassSkill< + [DynamicallyAccessedMembers( + DynamicallyAccessedMemberTypes.PublicProperties | + DynamicallyAccessedMemberTypes.NonPublicProperties | + DynamicallyAccessedMemberTypes.PublicMethods | + DynamicallyAccessedMemberTypes.NonPublicMethods)] TSelf> + : AgentSkill + where TSelf : AgentClassSkill { + private const BindingFlags DiscoveryBindingFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static; + private string? _content; + private bool _resourcesDiscovered; + private bool _scriptsDiscovered; + private IReadOnlyList? _reflectedResources; + private IReadOnlyList? _reflectedScripts; /// /// Gets the raw instructions text for this skill. /// protected abstract string Instructions { get; } + /// + /// Gets the used to marshal parameters and return values + /// for scripts and resources. + /// + /// + /// Override this property to provide custom serialization options. This value is used by + /// reflection-discovered scripts and resources, and also as a fallback by + /// and when no + /// explicit is passed to those methods. + /// The default value is , which causes to be used. + /// + protected virtual JsonSerializerOptions? SerializerOptions => null; + /// /// /// Returns a synthesized XML document containing name, description, instructions, resources, and scripts. @@ -65,6 +133,48 @@ public abstract class AgentClassSkill : AgentSkill this.Resources, this.Scripts); + /// + /// + /// Returns resources discovered via reflection by scanning for + /// members annotated with . This discovery is + /// compatible with Native AOT because is annotated with + /// . The result is cached after the first access. + /// + public override IReadOnlyList? Resources + { + get + { + if (!this._resourcesDiscovered) + { + this._reflectedResources = this.DiscoverResources(); + this._resourcesDiscovered = true; + } + + return this._reflectedResources; + } + } + + /// + /// + /// Returns scripts discovered via reflection by scanning for + /// methods annotated with . This discovery is + /// compatible with Native AOT because is annotated with + /// . The result is cached after the first access. + /// + public override IReadOnlyList? Scripts + { + get + { + if (!this._scriptsDiscovered) + { + this._reflectedScripts = this.DiscoverScripts(); + this._scriptsDiscovered = true; + } + + return this._reflectedScripts; + } + } + /// /// Creates a skill resource backed by a static value. /// @@ -72,7 +182,7 @@ public abstract class AgentClassSkill : AgentSkill /// The static resource value. /// An optional description of the resource. /// A new instance. - protected static AgentSkillResource CreateResource(string name, object value, string? description = null) + protected AgentSkillResource CreateResource(string name, object value, string? description = null) => new AgentInlineSkillResource(name, value, description); /// @@ -83,11 +193,11 @@ public abstract class AgentClassSkill : AgentSkill /// An optional description of the resource. /// /// Optional used to marshal the delegate's parameters and return value. - /// When , is used. + /// When , falls back to . /// /// A new instance. - protected static AgentSkillResource CreateResource(string name, Delegate method, string? description = null, JsonSerializerOptions? serializerOptions = null) - => new AgentInlineSkillResource(name, method, description, serializerOptions); + protected AgentSkillResource CreateResource(string name, Delegate method, string? description = null, JsonSerializerOptions? serializerOptions = null) + => new AgentInlineSkillResource(name, method, description, serializerOptions ?? this.SerializerOptions); /// /// Creates a skill script backed by a delegate. @@ -97,9 +207,129 @@ public abstract class AgentClassSkill : AgentSkill /// An optional description of the script. /// /// Optional used to marshal the delegate's parameters and return value. - /// When , is used. + /// When , falls back to . /// /// A new instance. - protected static AgentSkillScript CreateScript(string name, Delegate method, string? description = null, JsonSerializerOptions? serializerOptions = null) - => new AgentInlineSkillScript(name, method, description, serializerOptions); + protected AgentSkillScript CreateScript(string name, Delegate method, string? description = null, JsonSerializerOptions? serializerOptions = null) + => new AgentInlineSkillScript(name, method, description, serializerOptions ?? this.SerializerOptions); + + private List? DiscoverResources() + { + List? resources = null; + + var selfType = typeof(TSelf); + + // Discover resources from properties annotated with [AgentSkillResource]. + foreach (var property in selfType.GetProperties(DiscoveryBindingFlags)) + { + var attr = property.GetCustomAttribute(); + if (attr is null) + { + continue; + } + + var getter = property.GetGetMethod(nonPublic: true); + if (getter is null) + { + continue; + } + + // Indexer properties have getter parameters and cannot be used as resources + // because ReadAsync invokes the underlying AIFunction with no named arguments. + if (getter.GetParameters().Length > 0) + { + throw new InvalidOperationException( + $"Property '{property.Name}' on type '{selfType.Name}' is an indexer and cannot be used as a skill resource. " + + "Remove the [AgentSkillResource] attribute or use a non-indexer property."); + } + + var name = attr.Name ?? property.Name; + if (resources?.Exists(r => r.Name == name) == true) + { + throw new InvalidOperationException($"Skill '{this.Frontmatter.Name}' already has a resource named '{name}'. Ensure each [AgentSkillResource] has a unique name."); + } + + resources ??= []; + resources.Add(new AgentInlineSkillResource( + name: name, + method: getter, + target: getter.IsStatic ? null : this, + description: property.GetCustomAttribute()?.Description, + serializerOptions: this.SerializerOptions)); + } + + // Discover resources from methods annotated with [AgentSkillResource]. + foreach (var method in selfType.GetMethods(DiscoveryBindingFlags)) + { + var attr = method.GetCustomAttribute(); + if (attr is null) + { + continue; + } + + ValidateResourceMethodParameters(method, selfType); + + var name = attr.Name ?? method.Name; + if (resources?.Exists(r => r.Name == name) == true) + { + throw new InvalidOperationException($"Skill '{this.Frontmatter.Name}' already has a resource named '{name}'. Ensure each [AgentSkillResource] has a unique name."); + } + + resources ??= []; + resources.Add(new AgentInlineSkillResource( + name: name, + method: method, + target: method.IsStatic ? null : this, + description: method.GetCustomAttribute()?.Description, + serializerOptions: this.SerializerOptions)); + } + + return resources; + } + + private static void ValidateResourceMethodParameters(MethodInfo method, Type skillType) + { + foreach (var param in method.GetParameters()) + { + if (param.ParameterType != typeof(IServiceProvider) && + param.ParameterType != typeof(CancellationToken)) + { + throw new InvalidOperationException( + $"Method '{method.Name}' on type '{skillType.Name}' has parameter '{param.Name}' of type " + + $"'{param.ParameterType}' which cannot be supplied when reading a resource. " + + "Resource methods may only accept IServiceProvider and/or CancellationToken parameters. " + + "Remove the [AgentSkillResource] attribute or change the method signature."); + } + } + } + + private List? DiscoverScripts() + { + List? scripts = null; + + foreach (var method in typeof(TSelf).GetMethods(DiscoveryBindingFlags)) + { + var attr = method.GetCustomAttribute(); + if (attr is null) + { + continue; + } + + var name = attr.Name ?? method.Name; + if (scripts?.Exists(s => s.Name == name) == true) + { + throw new InvalidOperationException($"Skill '{this.Frontmatter.Name}' already has a script named '{name}'. Ensure each [AgentSkillScript] has a unique name."); + } + + scripts ??= []; + scripts.Add(new AgentInlineSkillScript( + name: name, + method: method, + target: method.IsStatic ? null : this, + description: method.GetCustomAttribute()?.Description, + serializerOptions: this.SerializerOptions)); + } + + return scripts; + } } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillResource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillResource.cs index 5e032f073f..556cfdc781 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillResource.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillResource.cs @@ -2,6 +2,7 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Reflection; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -54,6 +55,28 @@ internal sealed class AgentInlineSkillResource : AgentSkillResource this._function = AIFunctionFactory.Create(method, options); } + /// + /// Initializes a new instance of the class from a . + /// The method is invoked via an each time is called, + /// producing a dynamic (computed) value. + /// + /// The resource name. + /// A method that produces the resource value when requested. + /// The target instance for instance methods, or for static methods. + /// An optional description of the resource. + /// + /// Optional used to marshal the method's parameters and return value. + /// When , is used. + /// + public AgentInlineSkillResource(string name, MethodInfo method, object? target, string? description = null, JsonSerializerOptions? serializerOptions = null) + : base(name, description) + { + Throw.IfNull(method); + + var options = new AIFunctionFactoryOptions { Name = this.Name, SerializerOptions = serializerOptions }; + this._function = AIFunctionFactory.Create(method, target, options); + } + /// public override async Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) { diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs index 232a2fefce..1e3041aafc 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentInlineSkillScript.cs @@ -2,6 +2,7 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Reflection; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -39,6 +40,27 @@ internal sealed class AgentInlineSkillScript : AgentSkillScript this._function = AIFunctionFactory.Create(method, options); } + /// + /// Initializes a new instance of the class from a . + /// The method's parameters and return type are automatically marshaled via . + /// + /// The script name. + /// The method to execute when the script is invoked. + /// The target instance for instance methods, or for static methods. + /// An optional description of the script. + /// + /// Optional used to marshal the method's parameters and return value. + /// When , is used. + /// + public AgentInlineSkillScript(string name, MethodInfo method, object? target, string? description = null, JsonSerializerOptions? serializerOptions = null) + : base(Throw.IfNullOrWhitespace(name), description) + { + Throw.IfNull(method); + + var options = new AIFunctionFactoryOptions { Name = this.Name, SerializerOptions = serializerOptions }; + this._function = AIFunctionFactory.Create(method, target, options); + } + /// /// Gets the JSON schema describing the parameters accepted by this script, or if not available. /// diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentSkillResourceAttribute.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentSkillResourceAttribute.cs new file mode 100644 index 0000000000..a642d6c281 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentSkillResourceAttribute.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Marks a property or method as a skill resource that is automatically discovered by . +/// +/// +/// +/// Apply this attribute to properties or methods in an subclass to register +/// them as skill resources. +/// +/// +/// To provide a description for the resource, apply +/// to the same member. +/// +/// +/// When applied to a property, the property getter is invoked each time the resource is read, +/// enabling dynamic (computed) resources. When applied to a method, the method is invoked each time +/// the resource is read, also enabling dynamic resources. Methods with an +/// parameter support dependency injection. +/// +/// +/// This attribute is compatible with Native AOT when used with . +/// Alternatively, override the property and use +/// instead. +/// +/// +/// +/// +/// public class MySkill : AgentClassSkill<MySkill> +/// { +/// public override AgentSkillFrontmatter Frontmatter { get; } = new("my-skill", "A skill."); +/// protected override string Instructions => "Use this skill to do something."; +/// +/// [AgentSkillResource("reference-data")] +/// [Description("Some reference content for the skill.")] +/// public string ReferenceData => "Some reference content."; +/// } +/// +/// +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentSkillResourceAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// The resource name defaults to the property or method name. + /// + public AgentSkillResourceAttribute() + { + } + + /// + /// Initializes a new instance of the class + /// with an explicit resource name. + /// + /// The resource name used to identify this resource. + public AgentSkillResourceAttribute(string name) + { + this.Name = name; + } + + /// + /// Gets the resource name, or to use the member name. + /// + public string? Name { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentSkillScriptAttribute.cs b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentSkillScriptAttribute.cs new file mode 100644 index 0000000000..30f65cf383 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI/Skills/Programmatic/AgentSkillScriptAttribute.cs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI; + +/// +/// Marks a method as a skill script that is automatically discovered by . +/// +/// +/// +/// Apply this attribute to methods in an subclass to register them as +/// skill scripts. The method's parameters and return type are automatically marshaled via +/// AIFunctionFactory. +/// +/// +/// To provide a description for the script, apply +/// to the same method. +/// +/// +/// Methods can be instance or static, and may have any visibility (public, private, etc.). +/// Methods with an parameter support dependency injection. +/// +/// +/// This attribute is compatible with Native AOT when used with . +/// Alternatively, override the property and use +/// instead. +/// +/// +/// +/// +/// public class MySkill : AgentClassSkill<MySkill> +/// { +/// public override AgentSkillFrontmatter Frontmatter { get; } = new("my-skill", "A skill."); +/// protected override string Instructions => "Use this skill to do something."; +/// +/// [AgentSkillScript("do-something")] +/// [Description("Converts the input to upper case.")] +/// private static string DoSomething(string input) => input.ToUpperInvariant(); +/// } +/// +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)] +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public sealed class AgentSkillScriptAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// The script name defaults to the method name. + /// + public AgentSkillScriptAttribute() + { + } + + /// + /// Initializes a new instance of the class + /// with an explicit script name. + /// + /// The script name used to identify this script. + public AgentSkillScriptAttribute(string name) + { + this.Name = name; + } + + /// + /// Gets the script name, or to use the method name. + /// + public string? Name { get; } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs index f03c2d65fe..1dc7d5b3f9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentClassSkillTests.cs @@ -2,64 +2,60 @@ using System; using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Reflection; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; namespace Microsoft.Agents.AI.UnitTests.AgentSkills; /// -/// Unit tests for and . +/// Unit tests for and . /// public sealed class AgentClassSkillTests { [Fact] - public void Resources_DefaultsToNull_WhenNotOverridden() + public void MinimalClassSkill_HasNullOverrides_AndSynthesizesContent() { // Arrange var skill = new MinimalClassSkill(); - // Act & Assert + // Act & Assert — null overrides + Assert.Equal("minimal", skill.Frontmatter.Name); Assert.Null(skill.Resources); - } - - [Fact] - public void Scripts_DefaultsToNull_WhenNotOverridden() - { - // Arrange - var skill = new MinimalClassSkill(); - - // Act & Assert Assert.Null(skill.Scripts); + + // Act & Assert — synthesized XML content + Assert.Contains("minimal", skill.Content); + Assert.Contains("A minimal skill.", skill.Content); + Assert.Contains("", skill.Content); + Assert.Contains("Minimal skill body.", skill.Content); + Assert.Contains("", skill.Content); } [Fact] - public void Resources_ReturnsOverriddenList_WhenOverridden() + public void FullClassSkill_ReturnsOverriddenLists_AndCachesContent() { // Arrange var skill = new FullClassSkill(); - // Act - var resources = skill.Resources; + // Act & Assert — overridden resources and scripts + Assert.Single(skill.Resources!); + Assert.Equal("test-resource", skill.Resources![0].Name); - // Assert - Assert.Single(resources!); - Assert.Equal("test-resource", resources![0].Name); - } + Assert.Single(skill.Scripts!); + Assert.Equal("TestScript", skill.Scripts![0].Name); - [Fact] - public void Scripts_ReturnsOverriddenList_WhenOverridden() - { - // Arrange - var skill = new FullClassSkill(); + // Act & Assert — Content is cached + Assert.Same(skill.Content, skill.Content); - // Act - var scripts = skill.Scripts; - - // Assert - Assert.Single(scripts!); - Assert.Equal("TestScript", scripts![0].Name); + // Act & Assert — Content includes parameter schema from typed script + Assert.Contains("parameters_schema", skill.Content); + Assert.Contains("value", skill.Content); } [Fact] @@ -85,37 +81,11 @@ public sealed class AgentClassSkillTests Assert.Equal(1, skill.ScriptCreationCount); } - [Fact] - public void Name_Content_ReturnClassDefinedValues() - { - // Arrange - var skill = new MinimalClassSkill(); - - // Act & Assert - Assert.Equal("minimal", skill.Frontmatter.Name); - Assert.Contains("", skill.Content); - Assert.Contains("Minimal skill body.", skill.Content); - Assert.Contains("", skill.Content); - } - - [Fact] - public void Content_ReturnsSynthesizedXmlDocument() - { - // Arrange - var skill = new MinimalClassSkill(); - - // Act & Assert - Assert.Contains("minimal", skill.Content); - Assert.Contains("A minimal skill.", skill.Content); - Assert.Contains("", skill.Content); - Assert.Contains("Minimal skill body.", skill.Content); - } - [Fact] public async Task AgentInMemorySkillsSource_ReturnsAllSkillsAsync() { // Arrange - var skills = new AgentClassSkill[] { new MinimalClassSkill(), new FullClassSkill() }; + var skills = new AgentSkill[] { new MinimalClassSkill(), new FullClassSkill() }; var source = new AgentInMemorySkillsSource(skills); // Act @@ -135,203 +105,559 @@ public sealed class AgentClassSkillTests } [Fact] - public void SkillWithOnlyResources_HasNullScripts() + public void PartialOverrides_OneCollectionNull_OtherHasValues() { // Arrange - var skill = new ResourceOnlySkill(); + var resourceOnly = new ResourceOnlySkill(); + var scriptOnly = new ScriptOnlySkill(); // Act & Assert - Assert.Single(skill.Resources!); - Assert.Null(skill.Scripts); + Assert.Single(resourceOnly.Resources!); + Assert.Null(resourceOnly.Scripts); + Assert.Null(scriptOnly.Resources); + Assert.Single(scriptOnly.Scripts!); } [Fact] - public void SkillWithOnlyScripts_HasNullResources() + public async Task CreateScriptAndResource_WithSerializerOptions_HandleCustomTypesAsync() { // Arrange - var skill = new ScriptOnlySkill(); + var skill = new CustomTypeSkill(); + var jso = SkillTestJsonContext.Default.Options; + + // Act — script with custom type deserialization + var script = skill.Scripts![0]; + var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 5 }, jso); + var args = new AIFunctionArguments { ["request"] = inputJson }; + var scriptResult = await script.RunAsync(skill, args, CancellationToken.None); + + // Assert + Assert.NotNull(scriptResult); + var resultText = scriptResult!.ToString()!; + Assert.Contains("result for test", resultText); + Assert.Contains("5", resultText); + + // Act — resource with custom type serialization + var resourceResult = await skill.Resources![0].ReadAsync(); + + // Assert + Assert.NotNull(resourceResult); + Assert.Contains("dark", resourceResult!.ToString()!); + } + + [Fact] + public void Scripts_DiscoveredViaAttribute_WithCorrectNamesAndDescriptions() + { + // Arrange + var skill = new AttributedScriptsSkill(); + + // Act + var scripts = skill.Scripts; + + // Assert — all scripts discovered with correct metadata + Assert.NotNull(scripts); + Assert.Equal(4, scripts!.Count); + Assert.Contains(scripts, s => s.Name == "do-work"); + Assert.Contains(scripts, s => s.Name == "DefaultNamed"); + Assert.Contains(scripts, s => s.Name == "append"); + + var processScript = scripts.First(s => s.Name == "process"); + Assert.Equal("Processes the input.", processScript.Description); + } + + [Fact] + public async Task Scripts_DiscoveredViaAttribute_StaticAndInstance_CanBeInvokedAsync() + { + // Arrange + var skill = new AttributedScriptsSkill(); + + // Act & Assert — static method + var doWorkScript = skill.Scripts!.First(s => s.Name == "do-work"); + var doWorkResult = await doWorkScript.RunAsync(skill, new AIFunctionArguments { ["input"] = "hello" }, CancellationToken.None); + Assert.Equal("HELLO", doWorkResult?.ToString()); + + // Act & Assert — instance method + var appendScript = skill.Scripts!.First(s => s.Name == "append"); + var appendResult = await appendScript.RunAsync(skill, new AIFunctionArguments { ["input"] = "test" }, CancellationToken.None); + Assert.Equal("test-suffix", appendResult?.ToString()); + } + + [Fact] + public void Resources_DiscoveredViaAttribute_OnProperties_WithCorrectMetadata() + { + // Arrange + var skill = new AttributedResourcePropertiesSkill(); + + // Act + var resources = skill.Resources; + + // Assert — all resources discovered with correct metadata + Assert.NotNull(resources); + Assert.Equal(4, resources!.Count); + Assert.Contains(resources, r => r.Name == "ref-data"); + Assert.Contains(resources, r => r.Name == "DefaultNamed"); + Assert.Contains(resources, r => r.Name == "static-data"); + + var describedResource = resources.First(r => r.Name == "data"); + Assert.Equal("Some important data.", describedResource.Description); + } + + [Fact] + public async Task Resources_DiscoveredViaAttribute_OnProperties_CanBeReadAsync() + { + // Arrange + var skill = new AttributedResourcePropertiesSkill(); + + // Act & Assert — instance property + var refData = skill.Resources!.First(r => r.Name == "ref-data"); + Assert.Equal("Reference content.", (await refData.ReadAsync())?.ToString()); + + // Act & Assert — static property + var staticData = skill.Resources!.First(r => r.Name == "static-data"); + Assert.Equal("Static content.", (await staticData.ReadAsync())?.ToString()); + } + + [Fact] + public async Task Resources_DiscoveredViaAttribute_OnProperty_InvokedEachTimeAsync() + { + // Arrange + var skill = new AttributedResourceDynamicPropertySkill(); + var resource = skill.Resources![0]; + + // Act + var first = await resource.ReadAsync(); + var second = await resource.ReadAsync(); + + // Assert — property getter is called on each ReadAsync, producing different values + Assert.Equal("call-1", first?.ToString()); + Assert.Equal("call-2", second?.ToString()); + Assert.Equal(2, skill.CallCount); + } + + [Fact] + public void Resources_DiscoveredViaAttribute_OnMethods_WithCorrectMetadata() + { + // Arrange + var skill = new AttributedResourceMethodsSkill(); + + // Act + var resources = skill.Resources; + + // Assert + Assert.NotNull(resources); + Assert.Equal(4, resources!.Count); + Assert.Contains(resources, r => r.Name == "dynamic"); + Assert.Contains(resources, r => r.Name == "GetData"); + Assert.Contains(resources, r => r.Name == "instance-dynamic"); + + var describedResource = resources.First(r => r.Name == "info"); + Assert.Equal("Returns runtime info.", describedResource.Description); + } + + [Fact] + public async Task Resources_DiscoveredViaAttribute_OnMethods_CanBeReadAsync() + { + // Arrange + var skill = new AttributedResourceMethodsSkill(); + + // Act & Assert — static method + var dynamicResource = skill.Resources!.First(r => r.Name == "dynamic"); + Assert.Equal("dynamic-value", (await dynamicResource.ReadAsync())?.ToString()); + + // Act & Assert — instance method + var instanceResource = skill.Resources!.First(r => r.Name == "instance-dynamic"); + Assert.Equal("instance-method-value", (await instanceResource.ReadAsync())?.ToString()); + } + + [Fact] + public void AttributedFullSkill_IncludesContentWithSchema_AndCachesMembers() + { + // Arrange + var skill = new AttributedFullSkill(); + + // Act & Assert — Content includes reflected resources and scripts + Assert.Contains("", skill.Content); + Assert.Contains("conversion-table", skill.Content); + Assert.Contains("", skill.Content); + Assert.Contains("convert", skill.Content); + + // Act & Assert — discovered members are cached + Assert.Same(skill.Resources, skill.Resources); + Assert.Same(skill.Scripts, skill.Scripts); + + // Act & Assert — script has parameters schema + var script = skill.Scripts![0]; + Assert.NotNull(script.ParametersSchema); + Assert.Contains("value", script.ParametersSchema!.Value.GetRawText()); + } + + [Fact] + public void NoAttributedMembers_NoOverrides_ReturnsNull() + { + // Arrange — skill with no attributes and no overrides; base discovery returns null (not empty list) + var skill = new NoAttributesNoOverridesSkill(); + var baseType = typeof(AgentClassSkill); + var resourcesDiscoveredField = baseType.GetField("_resourcesDiscovered", BindingFlags.Instance | BindingFlags.NonPublic); + var scriptsDiscoveredField = baseType.GetField("_scriptsDiscovered", BindingFlags.Instance | BindingFlags.NonPublic); + var reflectedResourcesField = baseType.GetField("_reflectedResources", BindingFlags.Instance | BindingFlags.NonPublic); + var reflectedScriptsField = baseType.GetField("_reflectedScripts", BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.NotNull(resourcesDiscoveredField); + Assert.NotNull(scriptsDiscoveredField); + Assert.NotNull(reflectedResourcesField); + Assert.NotNull(reflectedScriptsField); + Assert.False((bool)resourcesDiscoveredField!.GetValue(skill)!); + Assert.False((bool)scriptsDiscoveredField!.GetValue(skill)!); // Act & Assert Assert.Null(skill.Resources); - Assert.Single(skill.Scripts!); + Assert.Null(skill.Scripts); + Assert.True((bool)resourcesDiscoveredField.GetValue(skill)!); + Assert.True((bool)scriptsDiscoveredField.GetValue(skill)!); + Assert.Null(reflectedResourcesField!.GetValue(skill)); + Assert.Null(reflectedScriptsField!.GetValue(skill)); + + // Repeated access should not re-trigger discovery even when discovered value is null. + Assert.Null(skill.Resources); + Assert.Null(skill.Scripts); + Assert.True((bool)resourcesDiscoveredField.GetValue(skill)!); + Assert.True((bool)scriptsDiscoveredField.GetValue(skill)!); + Assert.Null(reflectedResourcesField.GetValue(skill)); + Assert.Null(reflectedScriptsField.GetValue(skill)); } [Fact] - public void Content_ReturnsCachedInstance_OnRepeatedAccess() + public void SubclassOverride_TakesPrecedence_OverAttributes() { - // Arrange - var skill = new FullClassSkill(); + // Arrange — skill has attributes AND overrides Resources/Scripts + var skill = new AttributedWithOverrideSkill(); // Act - var first = skill.Content; - var second = skill.Content; + var resources = skill.Resources; + var scripts = skill.Scripts; - // Assert - Assert.Same(first, second); + // Assert — overrides win, not reflected members + Assert.NotNull(resources); + Assert.Single(resources!); + Assert.Equal("manual-resource", resources![0].Name); + Assert.NotNull(scripts); + Assert.Single(scripts!); + Assert.Equal("ManualScript", scripts![0].Name); } [Fact] - public void Content_IncludesParametersSchema_WhenScriptsHaveParameters() + public async Task MixedStaticAndInstance_AllDiscoveredAndInvocableAsync() { // Arrange - var skill = new FullClassSkill(); + var skill = new MixedStaticInstanceSkill(); + + // Act & Assert — correct counts + Assert.NotNull(skill.Resources); + Assert.Equal(2, skill.Resources!.Count); + Assert.NotNull(skill.Scripts); + Assert.Equal(2, skill.Scripts!.Count); + + // Act & Assert — all resources produce values + foreach (var resource in skill.Resources!) + { + var value = await resource.ReadAsync(); + Assert.NotNull(value); + } + + // Act & Assert — all scripts produce values + foreach (var script in skill.Scripts!) + { + var result = await script.RunAsync(skill, new AIFunctionArguments(), CancellationToken.None); + Assert.NotNull(result); + } + } + + [Fact] + public async Task SerializerOptions_UsedForReflectedMembersAsync() + { + // Arrange + var skill = new AttributedSkillWithCustomSerializer(); + var jso = SkillTestJsonContext.Default.Options; + + // Act & Assert — script with custom JSO + var script = skill.Scripts![0]; + var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 3 }, jso); + var args = new AIFunctionArguments { ["request"] = inputJson }; + var scriptResult = await script.RunAsync(skill, args, CancellationToken.None); + Assert.NotNull(scriptResult); + Assert.Contains("test", scriptResult!.ToString()!); + Assert.Contains("3", scriptResult!.ToString()!); + + // Act & Assert — resource with custom JSO + var resourceResult = await skill.Resources![0].ReadAsync(); + Assert.NotNull(resourceResult); + Assert.Contains("light", resourceResult!.ToString()!); + } + + [Fact] + public void Content_IncludesDescription_ForReflectedResources() + { + // Arrange + var skill = new AttributedResourcePropertiesSkill(); // Act var content = skill.Content; - // Assert — scripts with typed parameters should have their schema included - Assert.Contains("parameters_schema", content); - Assert.Contains("value", content); + // Assert — descriptions from [Description] attribute appear in synthesized content + Assert.Contains("Some important data.", content); } [Fact] - public void Content_IncludesDerivedResources_WhenResourcesUseBaseTypeOverrides() + public void IndexerPropertyWithResourceAttribute_ThrowsInvalidOperationException() { // Arrange - var skill = new DerivedResourceSkill(); + var skill = new IndexerResourceSkill(); - // Act - var content = skill.Content; - - // Assert - Assert.Contains("", content); - Assert.Contains("custom-resource", content); - Assert.Contains("Custom resource description.", content); + // Act & Assert — accessing Resources triggers discovery which should throw + var ex = Assert.Throws(() => skill.Resources); + Assert.Contains("indexer", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("IndexerResourceSkill", ex.Message); } [Fact] - public void Content_IncludesDerivedScripts_WhenScriptsUseBaseTypeOverrides() + public void ResourceMethodWithUnsupportedParameters_ThrowsInvalidOperationException() { // Arrange - var skill = new DerivedScriptSkill(); + var skill = new UnsupportedParamResourceMethodSkill(); - // Act - var content = skill.Content; - - // Assert - Assert.Contains("", content); - Assert.Contains("custom-script", content); - Assert.Contains("Custom script description.", content); + // Act & Assert — accessing Resources triggers discovery which should throw + var ex = Assert.Throws(() => skill.Resources); + Assert.Contains("content", ex.Message); + Assert.Contains("String", ex.Message); } [Fact] - public void Content_OmitsParametersSchema_WhenDerivedScriptDoesNotProvideOne() + public async Task ResourceMethodWithServiceProviderParam_IsDiscoveredSuccessfullyAsync() { // Arrange - var skill = new DerivedScriptSkill(); + var skill = new ServiceProviderResourceMethodSkill(); + var sp = new ServiceCollection().BuildServiceProvider(); // Act - var content = skill.Content; + var resources = skill.Resources; // Assert - Assert.DoesNotContain("parameters_schema", content); + Assert.NotNull(resources); + Assert.Single(resources!); + Assert.Equal("sp-resource", resources![0].Name); + + var value = await resources[0].ReadAsync(sp); + Assert.Equal("from-sp-method", value?.ToString()); + } + + [Fact] + public async Task ResourceMethodWithCancellationTokenParam_IsDiscoveredSuccessfullyAsync() + { + // Arrange + var skill = new CancellationTokenResourceMethodSkill(); + + // Act + var resources = skill.Resources; + + // Assert + Assert.NotNull(resources); + Assert.Single(resources!); + Assert.Equal("ct-resource", resources![0].Name); + + var value = await resources[0].ReadAsync(); + Assert.Equal("from-ct-method", value?.ToString()); + } + + [Fact] + public async Task ResourceMethodWithBothServiceProviderAndCancellationToken_IsDiscoveredSuccessfullyAsync() + { + // Arrange + var skill = new BothParamsResourceMethodSkill(); + var sp = new ServiceCollection().BuildServiceProvider(); + + // Act + var resources = skill.Resources; + + // Assert + Assert.NotNull(resources); + Assert.Single(resources!); + Assert.Equal("both-resource", resources![0].Name); + + var value = await resources[0].ReadAsync(sp); + Assert.Equal("from-both-method", value?.ToString()); + } + + [Fact] + public async Task CreateScript_FallsBackToSerializerOptions_WhenNoExplicitJsoAsync() + { + // Arrange + var skill = new CreateMethodsFallbackSkill(); + + // Act — invoke script that uses custom types, relying on SerializerOptions fallback + var script = skill.Scripts!.First(s => s.Name == "Lookup"); + var jso = SkillTestJsonContext.Default.Options; + var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "fallback", MaxResults = 7 }, jso); + var args = new AIFunctionArguments { ["request"] = inputJson }; + var result = await script.RunAsync(skill, args, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Contains("fallback", result!.ToString()!); + Assert.Contains("7", result!.ToString()!); + } + + [Fact] + public async Task CreateResource_FallsBackToSerializerOptions_WhenNoExplicitJsoAsync() + { + // Arrange + var skill = new CreateMethodsFallbackSkill(); + + // Act — read resource that uses custom types, relying on SerializerOptions fallback + var resource = skill.Resources!.First(r => r.Name == "config"); + var result = await resource.ReadAsync(); + + // Assert + Assert.NotNull(result); + Assert.Contains("dark", result!.ToString()!); + } + + [Fact] + public async Task CreateScript_UsesExplicitJso_OverSerializerOptionsAsync() + { + // Arrange + var skill = new CreateMethodsExplicitJsoSkill(); + + // Act — invoke script that passes explicit JSO (should take precedence over SerializerOptions) + var script = skill.Scripts!.First(s => s.Name == "Lookup"); + var jso = SkillTestJsonContext.Default.Options; + var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "explicit", MaxResults = 2 }, jso); + var args = new AIFunctionArguments { ["request"] = inputJson }; + var result = await script.RunAsync(skill, args, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Contains("explicit", result!.ToString()!); + Assert.Contains("2", result!.ToString()!); + } + + [Fact] + public async Task CreateResource_UsesExplicitJso_OverSerializerOptionsAsync() + { + // Arrange + var skill = new CreateMethodsExplicitJsoSkill(); + + // Act — read resource that passes explicit JSO (should take precedence over SerializerOptions) + var resource = skill.Resources!.First(r => r.Name == "config"); + var result = await resource.ReadAsync(); + + // Assert + Assert.NotNull(result); + Assert.Contains("explicit-theme", result!.ToString()!); + } + + [Fact] + public void DuplicateResourceNames_FromProperties_ThrowsInvalidOperationException() + { + // Arrange + var skill = new DuplicateResourcePropertiesSkill(); + + // Act & Assert + var ex = Assert.Throws(() => _ = skill.Resources); + Assert.Contains("data", ex.Message); + Assert.Contains("already has a resource", ex.Message); + } + + [Fact] + public void DuplicateResourceNames_FromPropertyAndMethod_ThrowsInvalidOperationException() + { + // Arrange + var skill = new DuplicateResourcePropertyAndMethodSkill(); + + // Act & Assert + var ex = Assert.Throws(() => _ = skill.Resources); + Assert.Contains("data", ex.Message); + Assert.Contains("already has a resource", ex.Message); + } + + [Fact] + public void DuplicateResourceNames_FromMethods_ThrowsInvalidOperationException() + { + // Arrange + var skill = new DuplicateResourceMethodsSkill(); + + // Act & Assert + var ex = Assert.Throws(() => _ = skill.Resources); + Assert.Contains("data", ex.Message); + Assert.Contains("already has a resource", ex.Message); + } + + [Fact] + public void DuplicateScriptNames_ThrowsInvalidOperationException() + { + // Arrange + var skill = new DuplicateScriptsSkill(); + + // Act & Assert + var ex = Assert.Throws(() => _ = skill.Scripts); + Assert.Contains("do-work", ex.Message); + Assert.Contains("already has a script", ex.Message); } #region Test skill classes - private sealed class MinimalClassSkill : AgentClassSkill + private sealed class MinimalClassSkill : AgentClassSkill { public override AgentSkillFrontmatter Frontmatter { get; } = new("minimal", "A minimal skill."); protected override string Instructions => "Minimal skill body."; - - public override IReadOnlyList? Resources => null; - - public override IReadOnlyList? Scripts => null; } - private sealed class FullClassSkill : AgentClassSkill + private sealed class FullClassSkill : AgentClassSkill { - private IReadOnlyList? _resources; - private IReadOnlyList? _scripts; - public override AgentSkillFrontmatter Frontmatter { get; } = new("full", "A full skill with resources and scripts."); protected override string Instructions => "Full skill body."; - public override IReadOnlyList? Resources => this._resources ??= + public override IReadOnlyList? Resources => [ - CreateResource("test-resource", "resource content"), + this.CreateResource("test-resource", "resource content"), ]; - public override IReadOnlyList? Scripts => this._scripts ??= + public override IReadOnlyList? Scripts => [ - CreateScript("TestScript", TestScript), + this.CreateScript("TestScript", TestScript), ]; private static string TestScript(double value) => JsonSerializer.Serialize(new { result = value * 2 }); } - private sealed class ResourceOnlySkill : AgentClassSkill + private sealed class ResourceOnlySkill : AgentClassSkill { - private IReadOnlyList? _resources; - public override AgentSkillFrontmatter Frontmatter { get; } = new("resource-only", "Skill with resources only."); protected override string Instructions => "Body."; - public override IReadOnlyList? Resources => this._resources ??= + public override IReadOnlyList? Resources => [ - CreateResource("data", "some data"), + this.CreateResource("data", "some data"), ]; - - public override IReadOnlyList? Scripts => null; } - private sealed class ScriptOnlySkill : AgentClassSkill + private sealed class ScriptOnlySkill : AgentClassSkill { - private IReadOnlyList? _scripts; - public override AgentSkillFrontmatter Frontmatter { get; } = new("script-only", "Skill with scripts only."); protected override string Instructions => "Body."; - public override IReadOnlyList? Resources => null; - - public override IReadOnlyList? Scripts => this._scripts ??= + public override IReadOnlyList? Scripts => [ - CreateScript("ToUpper", (string input) => input.ToUpperInvariant()), + this.CreateScript("ToUpper", (string input) => input.ToUpperInvariant()), ]; } - private sealed class DerivedResourceSkill : AgentClassSkill + private sealed class LazyLoadedSkill : AgentClassSkill { - private IReadOnlyList? _resources; - - public override AgentSkillFrontmatter Frontmatter { get; } = new("derived-resource", "Skill with a derived resource type."); - - protected override string Instructions => "Body."; - - public override IReadOnlyList? Resources => this._resources ??= - [ - new CustomResource("custom-resource", "Custom resource description."), - ]; - - public override IReadOnlyList? Scripts => null; - } - - private sealed class DerivedScriptSkill : AgentClassSkill - { - private IReadOnlyList? _scripts; - - public override AgentSkillFrontmatter Frontmatter { get; } = new("derived-script", "Skill with a derived script type."); - - protected override string Instructions => "Body."; - - public override IReadOnlyList? Resources => null; - - public override IReadOnlyList? Scripts => this._scripts ??= - [ - new CustomScript("custom-script", "Custom script description."), - ]; - } - - private sealed class LazyLoadedSkill : AgentClassSkill - { - private IReadOnlyList? _resources; - private IReadOnlyList? _scripts; - public override AgentSkillFrontmatter Frontmatter { get; } = new("lazy-loaded", "Skill with lazily created resources and scripts."); protected override string Instructions => "Body."; @@ -340,6 +666,9 @@ public sealed class AgentClassSkillTests public int ScriptCreationCount { get; private set; } + private IReadOnlyList? _resources; + private IReadOnlyList? _scripts; + public override IReadOnlyList? Resources => this._resources ??= this.CreateResources(); public override IReadOnlyList? Scripts => this._scripts ??= this.CreateScripts(); @@ -347,75 +676,17 @@ public sealed class AgentClassSkillTests private IReadOnlyList CreateResources() { this.ResourceCreationCount++; - return [CreateResource("lazy-resource", "resource content")]; + return [this.CreateResource("lazy-resource", "resource content")]; } private IReadOnlyList CreateScripts() { this.ScriptCreationCount++; - return [CreateScript("LazyScript", () => "done")]; + return [this.CreateScript("LazyScript", () => "done")]; } } - private sealed class CustomResource : AgentSkillResource - { - public CustomResource(string name, string? description = null) - : base(name, description) - { - } - - public override Task ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default) - => Task.FromResult("resource-value"); - } - - private sealed class CustomScript : AgentSkillScript - { - public CustomScript(string name, string? description = null) - : base(name, description) - { - } - - public override Task RunAsync(AgentSkill skill, AIFunctionArguments arguments, CancellationToken cancellationToken = default) - => Task.FromResult("script-result"); - } - - #endregion - - [Fact] - public async Task CreateScript_WithSerializerOptions_DeserializesCustomInputTypeAsync() - { - // Arrange - var skill = new CustomTypeSkill(); - var jso = SkillTestJsonContext.Default.Options; - - // Act — pass a custom type as JSON; the JSO enables deserialization - var script = skill.Scripts![0]; - var inputJson = JsonSerializer.SerializeToElement(new LookupRequest { Query = "test", MaxResults = 5 }, jso); - var args = new AIFunctionArguments { ["request"] = inputJson }; - var result = await script.RunAsync(skill, args, CancellationToken.None); - - // Assert — the custom input type was deserialized and the response was produced - Assert.NotNull(result); - var resultText = result!.ToString()!; - Assert.Contains("result for test", resultText); - Assert.Contains("5", resultText); - } - - [Fact] - public async Task CreateResource_WithSerializerOptions_SerializesReturnsCustomTypeAsync() - { - // Arrange - var skill = new CustomTypeSkill(); - - // Act - var result = await skill.Resources![0].ReadAsync(); - - // Assert — the custom type was returned successfully - Assert.NotNull(result); - Assert.Contains("dark", result!.ToString()!); - } - - private sealed class CustomTypeSkill : AgentClassSkill + private sealed class CustomTypeSkill : AgentClassSkill { public override AgentSkillFrontmatter Frontmatter { get; } = new("custom-type-skill", "Skill with custom-typed scripts and resources."); @@ -423,7 +694,7 @@ public sealed class AgentClassSkillTests public override IReadOnlyList? Resources => [ - CreateResource("config", () => new SkillConfig + this.CreateResource("config", () => new SkillConfig { Theme = "dark", Verbose = true @@ -432,11 +703,326 @@ public sealed class AgentClassSkillTests public override IReadOnlyList? Scripts => [ - CreateScript("Lookup", (LookupRequest request) => new LookupResponse + this.CreateScript("Lookup", (LookupRequest request) => new LookupResponse { Items = [$"result for {request.Query}"], TotalCount = request.MaxResults, }, serializerOptions: SkillTestJsonContext.Default.Options), ]; } + +#pragma warning disable IDE0051 // Remove unused private members + private sealed class AttributedScriptsSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-scripts", "Skill with various attributed scripts."); + + protected override string Instructions => "Body."; + + [AgentSkillScript("do-work")] + private static string DoWork(string input) => input.ToUpperInvariant(); + + [AgentSkillScript] + private static string DefaultNamed(string input) => input.ToUpperInvariant(); + + [AgentSkillScript("process")] + [Description("Processes the input.")] + private static string Process(string input) => input; + + [AgentSkillScript("append")] + private string Append(string input) => input + "-suffix"; + } + + private sealed class AttributedResourcePropertiesSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-resource-props", "Skill with various attributed resource properties."); + + protected override string Instructions => "Body."; + + [AgentSkillResource("ref-data")] + public string ReferenceData => "Reference content."; + + [AgentSkillResource] + public string DefaultNamed => "Some data."; + + [AgentSkillResource("data")] + [Description("Some important data.")] + public string DescribedData => "content"; + + [AgentSkillResource("static-data")] + public static string StaticData => "Static content."; + } + + private sealed class AttributedResourceMethodsSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-resource-methods", "Skill with various attributed resource methods."); + + protected override string Instructions => "Body."; + + [AgentSkillResource("dynamic")] + private static string GetDynamic() => "dynamic-value"; + + [AgentSkillResource] + private static string GetData() => "data"; + + [AgentSkillResource("info")] + [Description("Returns runtime info.")] + private static string GetInfo() => "runtime-info"; + + [AgentSkillResource("instance-dynamic")] + private string GetValue() => "instance-method-value"; + } + + private sealed class AttributedFullSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-full", "Full skill with attributed resources and scripts."); + + protected override string Instructions => "Convert units using the table."; + + [AgentSkillResource("conversion-table")] + public string ConversionTable => "miles -> km: 1.60934"; + + [AgentSkillScript("convert")] + private static string Convert(double value, double factor) => + JsonSerializer.Serialize(new { result = value * factor }); + } + + private sealed class NoAttributesNoOverridesSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("no-attrs", "Skill with no attributes or overrides."); + + protected override string Instructions => "Body."; + } + + private sealed class AttributedWithOverrideSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-override", "Skill with attributes and overrides."); + + protected override string Instructions => "Body."; + + // These attributes should be ignored because Resources/Scripts are overridden. + [AgentSkillResource("ignored-resource")] + public string IgnoredData => "ignored"; + + [AgentSkillScript("ignored-script")] + private static string IgnoredScript() => "ignored"; + + public override IReadOnlyList? Resources => + [ + this.CreateResource("manual-resource", "manual content"), + ]; + + public override IReadOnlyList? Scripts => + [ + this.CreateScript("ManualScript", () => "manual result"), + ]; + } + + private sealed class AttributedResourceDynamicPropertySkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-resource-dynamic-prop", "Skill with dynamic property resource."); + + protected override string Instructions => "Body."; + + public int CallCount { get; private set; } + + [AgentSkillResource("counter")] + public string Counter => $"call-{++this.CallCount}"; + } + + private sealed class AttributedSkillWithCustomSerializer : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("attributed-custom-jso", "Skill with custom serializer options."); + + protected override string Instructions => "Body."; + + protected override JsonSerializerOptions? SerializerOptions => SkillTestJsonContext.Default.Options; + + [AgentSkillResource("config")] + public SkillConfig Config => new() { Theme = "light", Verbose = false }; + + [AgentSkillScript("lookup")] + private static LookupResponse Lookup(LookupRequest request) => new() + { + Items = [$"result for {request.Query}"], + TotalCount = request.MaxResults, + }; + } + + private sealed class MixedStaticInstanceSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("mixed-static-instance", "Skill with both static and instance members."); + + protected override string Instructions => "Body."; + + [AgentSkillResource("static-resource")] + public static string StaticResource => "static-value"; + + [AgentSkillResource("instance-resource")] + public string InstanceResource => "instance-data"; + + [AgentSkillScript("static-script")] + private static string StaticScript() => "static-result"; + + [AgentSkillScript("instance-script")] + private string InstanceScript() => "instance-data"; + } + + private sealed class CreateMethodsFallbackSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("create-fallback", "Skill testing SerializerOptions fallback for CreateScript/CreateResource."); + + protected override string Instructions => "Body."; + + protected override JsonSerializerOptions? SerializerOptions => SkillTestJsonContext.Default.Options; + + public override IReadOnlyList? Resources => + [ + this.CreateResource("config", () => new SkillConfig + { + Theme = "dark", + Verbose = true, + }), + ]; + + public override IReadOnlyList? Scripts => + [ + this.CreateScript("Lookup", (LookupRequest request) => new LookupResponse + { + Items = [$"result for {request.Query}"], + TotalCount = request.MaxResults, + }), + ]; + } + + private sealed class CreateMethodsExplicitJsoSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("create-explicit-jso", "Skill testing explicit JSO overrides SerializerOptions."); + + protected override string Instructions => "Body."; + + // SerializerOptions is intentionally null — explicit JSO passed to CreateScript/CreateResource should be used. + public override IReadOnlyList? Resources => + [ + this.CreateResource("config", () => new SkillConfig + { + Theme = "explicit-theme", + Verbose = false, + }, serializerOptions: SkillTestJsonContext.Default.Options), + ]; + + public override IReadOnlyList? Scripts => + [ + this.CreateScript("Lookup", (LookupRequest request) => new LookupResponse + { + Items = [$"result for {request.Query}"], + TotalCount = request.MaxResults, + }, serializerOptions: SkillTestJsonContext.Default.Options), + ]; + } + + private sealed class IndexerResourceSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("indexer-skill", "Skill with indexer resource."); + + protected override string Instructions => "Body."; + + private readonly Dictionary _data = new() { ["key"] = "value" }; + + [AgentSkillResource("indexed")] + public string this[string key] => this._data[key]; + } + + private sealed class UnsupportedParamResourceMethodSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("unsupported-param-skill", "Skill with unsupported param resource method."); + + protected override string Instructions => "Body."; + + [AgentSkillResource("bad-resource")] + private static string GetData(string content) => content; + } + + private sealed class ServiceProviderResourceMethodSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("sp-param-skill", "Skill with IServiceProvider param resource method."); + + protected override string Instructions => "Body."; + + [AgentSkillResource("sp-resource")] + private static string GetData(IServiceProvider? sp) => "from-sp-method"; + } + + private sealed class CancellationTokenResourceMethodSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("ct-param-skill", "Skill with CancellationToken param resource method."); + + protected override string Instructions => "Body."; + + [AgentSkillResource("ct-resource")] + private static string GetData(CancellationToken ct) => "from-ct-method"; + } + + private sealed class BothParamsResourceMethodSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("both-param-skill", "Skill with both IServiceProvider and CancellationToken param resource method."); + + protected override string Instructions => "Body."; + + [AgentSkillResource("both-resource")] + private static string GetData(IServiceProvider? sp, CancellationToken ct) => "from-both-method"; + } + private sealed class DuplicateResourcePropertiesSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("dup-res-props", "Skill with duplicate resource property names."); + + protected override string Instructions => "Body."; + + [AgentSkillResource("data")] + public string Data1 => "value1"; + + [AgentSkillResource("data")] + public string Data2 => "value2"; + } + + private sealed class DuplicateResourcePropertyAndMethodSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("dup-res-prop-method", "Skill with duplicate resource from property and method."); + + protected override string Instructions => "Body."; + + [AgentSkillResource("data")] + public string Data => "property-value"; + + [AgentSkillResource("data")] + private static string GetData() => "method-value"; + } + + private sealed class DuplicateResourceMethodsSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("dup-res-methods", "Skill with duplicate resource method names."); + + protected override string Instructions => "Body."; + + [AgentSkillResource("data")] + private static string GetData1() => "value1"; + + [AgentSkillResource("data")] + private static string GetData2() => "value2"; + } + + private sealed class DuplicateScriptsSkill : AgentClassSkill + { + public override AgentSkillFrontmatter Frontmatter { get; } = new("dup-scripts", "Skill with duplicate script names."); + + protected override string Instructions => "Body."; + + [AgentSkillScript("do-work")] + private static string DoWork1(string input) => input.ToUpperInvariant(); + + [AgentSkillScript("do-work")] + private static string DoWork2(string input) => input + "-suffix"; + } +#pragma warning restore IDE0051 // Remove unused private members + + #endregion } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillResourceTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillResourceTests.cs index 3901e61c7c..46724ca9b5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillResourceTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillResourceTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Reflection; using System.Threading; using System.Threading.Tasks; @@ -167,4 +168,58 @@ public sealed class AgentInlineSkillResourceTests // Assert Assert.Equal("value", result); } + + [Fact] + public void Constructor_MethodInfo_SetsNameAndDescription() + { + // Arrange + var method = typeof(AgentInlineSkillResourceTests).GetMethod(nameof(StaticResourceHelper), BindingFlags.NonPublic | BindingFlags.Static)!; + + // Act + var resource = new AgentInlineSkillResource("method-resource", method, target: null, description: "A method resource."); + + // Assert + Assert.Equal("method-resource", resource.Name); + Assert.Equal("A method resource.", resource.Description); + } + + [Fact] + public async Task ReadAsync_MethodInfo_StaticMethod_ReturnsValueAsync() + { + // Arrange + var method = typeof(AgentInlineSkillResourceTests).GetMethod(nameof(StaticResourceHelper), BindingFlags.NonPublic | BindingFlags.Static)!; + var resource = new AgentInlineSkillResource("static-method-res", method, target: null); + + // Act + var result = await resource.ReadAsync(); + + // Assert + Assert.Equal("static-resource-value", result?.ToString()); + } + + [Fact] + public async Task ReadAsync_MethodInfo_InstanceMethod_ReturnsValueAsync() + { + // Arrange + var method = typeof(AgentInlineSkillResourceTests).GetMethod(nameof(InstanceResourceHelper), BindingFlags.NonPublic | BindingFlags.Instance)!; + var resource = new AgentInlineSkillResource("instance-method-res", method, target: this); + + // Act + var result = await resource.ReadAsync(); + + // Assert + Assert.Equal("instance-resource-value", result?.ToString()); + } + + [Fact] + public void Constructor_MethodInfo_NullMethod_Throws() + { + // Act & Assert + Assert.Throws(() => + new AgentInlineSkillResource("my-res", null!, target: null)); + } + + private static string StaticResourceHelper() => "static-resource-value"; + + private string InstanceResourceHelper() => "instance-resource-value"; } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillScriptTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillScriptTests.cs index 5d5dc5bd02..efab3b2c7d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillScriptTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentInlineSkillScriptTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Reflection; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -152,4 +153,77 @@ public sealed class AgentInlineSkillScriptTests // Assert Assert.Equal("hello world", result?.ToString()); } + + [Fact] + public void Constructor_MethodInfo_SetsNameAndDescription() + { + // Arrange + var method = typeof(AgentInlineSkillScriptTests).GetMethod(nameof(StaticScriptHelper), BindingFlags.NonPublic | BindingFlags.Static)!; + + // Act + var script = new AgentInlineSkillScript("method-script", method, target: null, description: "A method script."); + + // Assert + Assert.Equal("method-script", script.Name); + Assert.Equal("A method script.", script.Description); + } + + [Fact] + public async Task RunAsync_MethodInfo_StaticMethod_InvokesAndReturnsAsync() + { + // Arrange + var method = typeof(AgentInlineSkillScriptTests).GetMethod(nameof(StaticScriptHelper), BindingFlags.NonPublic | BindingFlags.Static)!; + var script = new AgentInlineSkillScript("static-method-script", method, target: null); + var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions."); + var args = new AIFunctionArguments { ["input"] = "hello" }; + + // Act + var result = await script.RunAsync(skill, args, CancellationToken.None); + + // Assert + Assert.Equal("HELLO", result?.ToString()); + } + + [Fact] + public async Task RunAsync_MethodInfo_InstanceMethod_InvokesAndReturnsAsync() + { + // Arrange + var method = typeof(AgentInlineSkillScriptTests).GetMethod(nameof(InstanceScriptHelper), BindingFlags.NonPublic | BindingFlags.Instance)!; + var script = new AgentInlineSkillScript("instance-method-script", method, target: this); + var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions."); + var args = new AIFunctionArguments { ["input"] = "test" }; + + // Act + var result = await script.RunAsync(skill, args, CancellationToken.None); + + // Assert + Assert.Equal("test-suffix", result?.ToString()); + } + + [Fact] + public void Constructor_MethodInfo_NullMethod_Throws() + { + // Act & Assert + Assert.Throws(() => + new AgentInlineSkillScript("my-script", null!, target: null)); + } + + [Fact] + public void ParametersSchema_MethodInfo_ContainsParameterNames() + { + // Arrange + var method = typeof(AgentInlineSkillScriptTests).GetMethod(nameof(StaticScriptHelper), BindingFlags.NonPublic | BindingFlags.Static)!; + var script = new AgentInlineSkillScript("param-script", method, target: null); + + // Act + var schema = script.ParametersSchema; + + // Assert + Assert.NotNull(schema); + Assert.Contains("input", schema!.Value.GetRawText()); + } + + private static string StaticScriptHelper(string input) => input.ToUpperInvariant(); + + private string InstanceScriptHelper(string input) => input + "-suffix"; } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillResourceAttributeTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillResourceAttributeTests.cs new file mode 100644 index 0000000000..e4dee59c88 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillResourceAttributeTests.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class AgentSkillResourceAttributeTests +{ + [Fact] + public void DefaultConstructor_NameIsNull() + { + // Arrange & Act + var attr = new AgentSkillResourceAttribute(); + + // Assert + Assert.Null(attr.Name); + } + + [Fact] + public void NamedConstructor_SetsName() + { + // Arrange & Act + var attr = new AgentSkillResourceAttribute("my-resource"); + + // Assert + Assert.Equal("my-resource", attr.Name); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillScriptAttributeTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillScriptAttributeTests.cs new file mode 100644 index 0000000000..937c05e8a1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillScriptAttributeTests.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.UnitTests.AgentSkills; + +/// +/// Unit tests for . +/// +public sealed class AgentSkillScriptAttributeTests +{ + [Fact] + public void DefaultConstructor_NameIsNull() + { + // Arrange & Act + var attr = new AgentSkillScriptAttribute(); + + // Assert + Assert.Null(attr.Name); + } + + [Fact] + public void NamedConstructor_SetsName() + { + // Arrange & Act + var attr = new AgentSkillScriptAttribute("my-script"); + + // Assert + Assert.Equal("my-script", attr.Name); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs index e86eb0894a..23c2745247 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentSkillsProviderTests.cs @@ -871,7 +871,7 @@ public sealed class AgentSkillsProviderTests : IDisposable public async Task Constructor_ClassSkillsEnumerable_ProvidesSkillsAsync() { // Arrange - var skills = new List + var skills = new List { new TestClassSkill("enum-class-a", "Class A", "Instructions A."), new TestClassSkill("enum-class-b", "Class B", "Instructions B."), @@ -928,7 +928,7 @@ public sealed class AgentSkillsProviderTests : IDisposable } } - private sealed class TestClassSkill : AgentClassSkill + private sealed class TestClassSkill : AgentClassSkill { private readonly string _instructions; From 14d2ab3262580a383472b406d97b36cfd86b2787 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:27:45 +0100 Subject: [PATCH 16/22] Standardize file skills terminology on 'directory' (#5205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename authored identifiers, XML docs, log messages, and comments from 'folder' to 'directory' across the file skills codebase for consistency with the agentskills.io specification and .NET conventions. Public API changes (experimental): - ScriptFolders → ScriptDirectories - ResourceFolders → ResourceDirectories .NET BCL API calls (Directory.Exists, Path.GetDirectoryName, etc.) were already using 'directory' and are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Skills/File/AgentFileSkillsSource.cs | 108 +++++++-------- .../File/AgentFileSkillsSourceOptions.cs | 8 +- .../AgentFileSkillsSourceScriptTests.cs | 36 ++--- .../AgentSkills/FileAgentSkillLoaderTests.cs | 130 +++++++++--------- 4 files changed, 141 insertions(+), 141 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs index 79595f17a2..972dbee53f 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSource.cs @@ -32,15 +32,15 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource private const string SkillFileName = "SKILL.md"; private const int MaxSearchDepth = 2; - // "." means the skill directory root itself (no sub-folder descent constraint) - private const string RootFolderIndicator = "."; + // "." means the skill directory root itself (no subdirectory descent constraint) + private const string RootDirectoryIndicator = "."; private static readonly string[] s_defaultScriptExtensions = [".py", ".js", ".sh", ".ps1", ".cs", ".csx"]; private static readonly string[] s_defaultResourceExtensions = [".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt"]; - // Standard sub-folder names per https://agentskills.io/specification#directory-structure - private static readonly string[] s_defaultScriptFolders = ["scripts"]; - private static readonly string[] s_defaultResourceFolders = ["references", "assets"]; + // Standard subdirectory names per https://agentskills.io/specification#directory-structure + private static readonly string[] s_defaultScriptDirectories = ["scripts"]; + private static readonly string[] s_defaultResourceDirectories = ["references", "assets"]; // Matches YAML frontmatter delimited by "---" lines. Group 1 = content between delimiters. // Multiline makes ^/$ match line boundaries; Singleline makes . match newlines across the block. @@ -63,8 +63,8 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource private readonly IEnumerable _skillPaths; private readonly HashSet _allowedResourceExtensions; private readonly HashSet _allowedScriptExtensions; - private readonly IReadOnlyList _scriptFolders; - private readonly IReadOnlyList _resourceFolders; + private readonly IReadOnlyList _scriptDirectories; + private readonly IReadOnlyList _resourceDirectories; private readonly AgentFileSkillScriptRunner? _scriptRunner; private readonly ILogger _logger; @@ -111,13 +111,13 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource options?.AllowedScriptExtensions ?? s_defaultScriptExtensions, StringComparer.OrdinalIgnoreCase); - this._scriptFolders = options?.ScriptFolders is not null - ? [.. ValidateAndNormalizeFolderNames(options.ScriptFolders, this._logger)] - : s_defaultScriptFolders; + this._scriptDirectories = options?.ScriptDirectories is not null + ? [.. ValidateAndNormalizeDirectoryNames(options.ScriptDirectories, this._logger)] + : s_defaultScriptDirectories; - this._resourceFolders = options?.ResourceFolders is not null - ? [.. ValidateAndNormalizeFolderNames(options.ResourceFolders, this._logger)] - : s_defaultResourceFolders; + this._resourceDirectories = options?.ResourceDirectories is not null + ? [.. ValidateAndNormalizeDirectoryNames(options.ResourceDirectories, this._logger)] + : s_defaultResourceDirectories; this._scriptRunner = scriptRunner; } @@ -303,12 +303,12 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource } /// - /// Scans configured resource folders within a skill directory for resource files matching the configured extensions. + /// Scans configured resource directories within a skill directory for resource files matching the configured extensions. /// /// - /// By default, scans references/ and assets/ sub-folders as specified by the + /// By default, scans references/ and assets/ subdirectories as specified by the /// Agent Skills specification. - /// Configure to scan different or + /// Configure to scan different or /// additional directories, including "." for the skill root itself. /// Each file is validated against path-traversal and symlink-escape checks; unsafe files are skipped. /// @@ -316,14 +316,14 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource { var resources = new List(); - foreach (string folder in this._resourceFolders.Distinct(StringComparer.OrdinalIgnoreCase)) + foreach (string directory in this._resourceDirectories.Distinct(StringComparer.OrdinalIgnoreCase)) { - bool isRootFolder = string.Equals(folder, RootFolderIndicator, StringComparison.Ordinal); + bool isRootDirectory = string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal); // GetFullPath normalizes mixed separators (e.g. "C:\skill\scripts/f1" → "C:\skill\scripts\f1") - string targetDirectory = isRootFolder + string targetDirectory = isRootDirectory ? skillDirectoryFullPath - : Path.GetFullPath(Path.Combine(skillDirectoryFullPath, folder)) + Path.DirectorySeparatorChar; + : Path.GetFullPath(Path.Combine(skillDirectoryFullPath, directory)) + Path.DirectorySeparatorChar; if (!Directory.Exists(targetDirectory)) { @@ -331,13 +331,13 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource } // Directory-level symlink check: skip if targetDirectory (or any intermediate - // segment) is a reparse point. The root folder is excluded — it's a caller-supplied + // segment) is a reparse point. The root directory is excluded — it's a caller-supplied // trusted path, and the security boundary guards files within it, not the path itself. - if (!isRootFolder && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath)) + if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath)) { if (this._logger.IsEnabled(LogLevel.Warning)) { - LogResourceSymlinkFolder(this._logger, skillName, SanitizePathForLog(folder)); + LogResourceSymlinkDirectory(this._logger, skillName, SanitizePathForLog(directory)); } continue; @@ -380,7 +380,7 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource // e.g. "references/../../../etc/shadow" → "/etc/shadow" string resolvedFilePath = Path.GetFullPath(filePath); - // Path containment: reject if the resolved path escapes the target folder. + // Path containment: reject if the resolved path escapes the target directory. // e.g. "/etc/shadow".StartsWith("/skills/myskill/references/") → false → skip if (!resolvedFilePath.StartsWith(targetDirectory, StringComparison.OrdinalIgnoreCase)) { @@ -416,12 +416,12 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource } /// - /// Scans configured script folders within a skill directory for script files matching the configured extensions. + /// Scans configured script directories within a skill directory for script files matching the configured extensions. /// /// - /// By default, scans the scripts/ sub-folder as specified by the + /// By default, scans the scripts/ subdirectory as specified by the /// Agent Skills specification. - /// Configure to scan different or + /// Configure to scan different or /// additional directories, including "." for the skill root itself. /// Each file is validated against path-traversal and symlink-escape checks; unsafe files are skipped. /// @@ -429,14 +429,14 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource { var scripts = new List(); - foreach (string folder in this._scriptFolders.Distinct(StringComparer.OrdinalIgnoreCase)) + foreach (string directory in this._scriptDirectories.Distinct(StringComparer.OrdinalIgnoreCase)) { - bool isRootFolder = string.Equals(folder, RootFolderIndicator, StringComparison.Ordinal); + bool isRootDirectory = string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal); // GetFullPath normalizes mixed separators (e.g. "C:\skill\scripts/f1" → "C:\skill\scripts\f1") - string targetDirectory = isRootFolder + string targetDirectory = isRootDirectory ? skillDirectoryFullPath - : Path.GetFullPath(Path.Combine(skillDirectoryFullPath, folder)) + Path.DirectorySeparatorChar; + : Path.GetFullPath(Path.Combine(skillDirectoryFullPath, directory)) + Path.DirectorySeparatorChar; if (!Directory.Exists(targetDirectory)) { @@ -444,13 +444,13 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource } // Directory-level symlink check: skip if targetDirectory (or any intermediate - // segment) is a reparse point. The root folder is excluded — it's a caller-supplied + // segment) is a reparse point. The root directory is excluded — it's a caller-supplied // trusted path, and the security boundary guards files within it, not the path itself. - if (!isRootFolder && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath)) + if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath)) { if (this._logger.IsEnabled(LogLevel.Warning)) { - LogScriptSymlinkFolder(this._logger, skillName, SanitizePathForLog(folder)); + LogScriptSymlinkDirectory(this._logger, skillName, SanitizePathForLog(directory)); } continue; @@ -480,7 +480,7 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource // e.g. "scripts/../../../etc/shadow" → "/etc/shadow" string resolvedFilePath = Path.GetFullPath(filePath); - // Path containment: reject if the resolved path escapes the target folder. + // Path containment: reject if the resolved path escapes the target directory. // e.g. "/etc/shadow".StartsWith("/skills/myskill/scripts/") → false → skip if (!resolvedFilePath.StartsWith(targetDirectory, StringComparison.OrdinalIgnoreCase)) { @@ -541,8 +541,8 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource } /// - /// Normalizes a relative path or folder name by stripping a leading "./"/".\", - /// trimming trailing directory separators, and replacing backslashes with forward + /// Normalizes a relative path or directory name by stripping a leading "./"/".\", + /// trimming trailing separators, and replacing backslashes with forward /// slashes. /// private static string NormalizePath(string path) @@ -602,36 +602,36 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource } } - private static IEnumerable ValidateAndNormalizeFolderNames(IEnumerable folders, ILogger logger) + private static IEnumerable ValidateAndNormalizeDirectoryNames(IEnumerable directories, ILogger logger) { - foreach (string folder in folders) + foreach (string directory in directories) { - if (string.IsNullOrWhiteSpace(folder)) + if (string.IsNullOrWhiteSpace(directory)) { - throw new ArgumentException("Folder names must not be null or whitespace.", nameof(folders)); + throw new ArgumentException("Directory names must not be null or whitespace.", nameof(directories)); } // "." is valid — it means the skill root directory. - if (string.Equals(folder, RootFolderIndicator, StringComparison.Ordinal)) + if (string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal)) { - yield return folder; + yield return directory; continue; } // Reject absolute paths and any path segments that escape upward. - if (Path.IsPathRooted(folder) || ContainsParentTraversalSegment(folder)) + if (Path.IsPathRooted(directory) || ContainsParentTraversalSegment(directory)) { - LogFolderNameSkippedInvalid(logger, folder); + LogDirectoryNameSkippedInvalid(logger, directory); continue; } - yield return NormalizePath(folder); + yield return NormalizePath(directory); } } - private static bool ContainsParentTraversalSegment(string folder) + private static bool ContainsParentTraversalSegment(string directory) { - foreach (string segment in folder.Split('/', '\\')) + foreach (string segment in directory.Split('/', '\\')) { if (segment == "..") { @@ -666,8 +666,8 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource [LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' is a symlink that resolves outside the skill directory")] private static partial void LogResourceSymlinkEscape(ILogger logger, string skillName, string resourcePath); - [LoggerMessage(LogLevel.Warning, "Skipping resource folder '{FolderName}' in skill '{SkillName}': folder path contains a symlink")] - private static partial void LogResourceSymlinkFolder(ILogger logger, string skillName, string folderName); + [LoggerMessage(LogLevel.Warning, "Skipping resource directory '{DirectoryName}' in skill '{SkillName}': directory path contains a symlink")] + private static partial void LogResourceSymlinkDirectory(ILogger logger, string skillName, string directoryName); [LoggerMessage(LogLevel.Debug, "Skipping file '{FilePath}' in skill '{SkillName}': extension '{Extension}' is not in the allowed list")] private static partial void LogResourceSkippedExtension(ILogger logger, string skillName, string filePath, string extension); @@ -678,9 +678,9 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource [LoggerMessage(LogLevel.Warning, "Skipping script in skill '{SkillName}': '{ScriptPath}' is a symlink that resolves outside the skill directory")] private static partial void LogScriptSymlinkEscape(ILogger logger, string skillName, string scriptPath); - [LoggerMessage(LogLevel.Warning, "Skipping script folder '{FolderName}' in skill '{SkillName}': folder path contains a symlink")] - private static partial void LogScriptSymlinkFolder(ILogger logger, string skillName, string folderName); + [LoggerMessage(LogLevel.Warning, "Skipping script directory '{DirectoryName}' in skill '{SkillName}': directory path contains a symlink")] + private static partial void LogScriptSymlinkDirectory(ILogger logger, string skillName, string directoryName); - [LoggerMessage(LogLevel.Warning, "Skipping invalid folder name '{FolderName}': must be a relative path with no '..' segments")] - private static partial void LogFolderNameSkippedInvalid(ILogger logger, string folderName); + [LoggerMessage(LogLevel.Warning, "Skipping invalid directory name '{DirectoryName}': must be a relative path with no '..' segments")] + private static partial void LogDirectoryNameSkippedInvalid(ILogger logger, string directoryName); } diff --git a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs index fcd9398104..b5c83c0220 100644 --- a/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI/Skills/File/AgentFileSkillsSourceOptions.cs @@ -32,7 +32,7 @@ public sealed class AgentFileSkillsSourceOptions public IEnumerable? AllowedScriptExtensions { get; set; } /// - /// Gets or sets relative folder paths to scan for script files within each skill directory. + /// Gets or sets relative directory paths to scan for script files within each skill directory. /// Values may be single-segment names (e.g., "scripts") or multi-segment relative /// paths (e.g., "sub/scripts"). Use "." to include files directly at the /// skill root. Leading "./" prefixes, trailing separators, and backslashes are @@ -42,10 +42,10 @@ public sealed class AgentFileSkillsSourceOptions /// Agent Skills specification). /// When set, replaces the defaults entirely. /// - public IEnumerable? ScriptFolders { get; set; } + public IEnumerable? ScriptDirectories { get; set; } /// - /// Gets or sets relative folder paths to scan for resource files within each skill directory. + /// Gets or sets relative directory paths to scan for resource files within each skill directory. /// Values may be single-segment names (e.g., "references") or multi-segment relative /// paths (e.g., "sub/resources"). Use "." to include files directly at the /// skill root. Leading "./" prefixes, trailing separators, and backslashes are @@ -55,5 +55,5 @@ public sealed class AgentFileSkillsSourceOptions /// Agent Skills specification). /// When set, replaces the defaults entirely. /// - public IEnumerable? ResourceFolders { get; set; } + public IEnumerable? ResourceDirectories { get; set; } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs index d524b6142a..f1ca662202 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/AgentFileSkillsSourceScriptTests.cs @@ -116,8 +116,8 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable [Fact] public async Task GetSkillsAsync_ScriptsOutsideScriptsDir_AreNotDiscoveredAsync() { - // Arrange — scripts outside configured folders are not discovered; only files directly - // inside the configured folder are picked up (no subdirectory recursion) + // Arrange — scripts outside configured directories are not discovered; only files directly + // inside the configured directory are picked up (no subdirectory recursion) string skillDir = CreateSkillDir(this._testRoot, "root-scripts", "Root scripts skill", "Body."); CreateFile(skillDir, "convert.py", "print('root')"); CreateFile(skillDir, "tools/helper.sh", "echo 'helper'"); @@ -126,7 +126,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable // Act var skills = await source.GetSkillsAsync(CancellationToken.None); - // Assert — neither file is in the default scripts/ folder, so no scripts are discovered + // Assert — neither file is in the default scripts/ directory, so no scripts are discovered Assert.Single(skills); Assert.Empty(skills[0].Scripts!); } @@ -229,18 +229,18 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable } [Fact] - public async Task GetSkillsAsync_ScriptFoldersWithNestedPath_DiscoversScriptsAsync() + public async Task GetSkillsAsync_ScriptDirectoriesWithNestedPath_DiscoversScriptsAsync() { - // Arrange — ScriptFolders configured with a multi-segment relative path (f1/f2/f3) - string skillDir = CreateSkillDir(this._testRoot, "nested-script-skill", "Nested script folder", "Body."); + // Arrange — ScriptDirectories configured with a multi-segment relative path (f1/f2/f3) + string skillDir = CreateSkillDir(this._testRoot, "nested-script-skill", "Nested script directory", "Body."); CreateFile(skillDir, "f1/f2/f3/run.py", "print('nested')"); var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, - new AgentFileSkillsSourceOptions { ScriptFolders = ["f1/f2/f3"] }); + new AgentFileSkillsSourceOptions { ScriptDirectories = ["f1/f2/f3"] }); // Act var skills = await source.GetSkillsAsync(CancellationToken.None); - // Assert — script file inside the deeply nested folder is discovered + // Assert — script file inside the deeply nested directory is discovered Assert.Single(skills); Assert.Single(skills[0].Scripts!); Assert.Equal("f1/f2/f3/run.py", skills[0].Scripts![0].Name); @@ -250,29 +250,29 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable [InlineData("./scripts")] [InlineData("./scripts/f1")] [InlineData("./scripts/f1", "./f2")] - public async Task GetSkillsAsync_ScriptFolderWithDotSlashPrefix_DiscoversScriptsAsync(params string[] folders) + public async Task GetSkillsAsync_ScriptDirectoryWithDotSlashPrefix_DiscoversScriptsAsync(params string[] directories) { - // Arrange — "./"-prefixed folders are equivalent to their counterparts without the prefix; + // Arrange — "./"-prefixed directories are equivalent to their counterparts without the prefix; // the leading "./" is transparently normalized by Path.GetFullPath during file enumeration. string skillDir = CreateSkillDir(this._testRoot, "dotslash-script-skill", "Dot-slash prefix", "Body."); - foreach (string folder in folders) + foreach (string directory in directories) { - string folderWithoutDotSlash = folder.Substring(2); // strip "./" - CreateFile(skillDir, $"{folderWithoutDotSlash}/run.py", "print('dotslash')"); + string directoryWithoutDotSlash = directory.Substring(2); // strip "./" + CreateFile(skillDir, $"{directoryWithoutDotSlash}/run.py", "print('dotslash')"); } var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, - new AgentFileSkillsSourceOptions { ScriptFolders = folders }); + new AgentFileSkillsSourceOptions { ScriptDirectories = directories }); // Act var skills = await source.GetSkillsAsync(CancellationToken.None); - // Assert — scripts are discovered with names identical to using folders without "./" + // Assert — scripts are discovered with names identical to using directories without "./" Assert.Single(skills); - Assert.Equal(folders.Length, skills[0].Scripts!.Count); - foreach (string folder in folders) + Assert.Equal(directories.Length, skills[0].Scripts!.Count); + foreach (string directory in directories) { - string expectedName = $"{folder.Substring(2)}/run.py"; + string expectedName = $"{directory.Substring(2)}/run.py"; Assert.Contains(skills[0].Scripts!, s => s.Name == expectedName); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs index ca0884ea43..c43568acd9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/AgentSkills/FileAgentSkillLoaderTests.cs @@ -199,7 +199,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable [Fact] public async Task GetSkillsAsync_FilesWithMatchingExtensions_DiscoveredAsResourcesAsync() { - // Arrange — create resource files in spec-defined sub-folders + // Arrange — create resource files in spec-defined subdirectories string skillDir = Path.Combine(this._testRoot, "resource-skill"); string refsDir = Path.Combine(skillDir, "references"); string assetsDir = Path.Combine(skillDir, "assets"); @@ -226,7 +226,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable [Fact] public async Task GetSkillsAsync_FilesWithNonMatchingExtensions_NotDiscoveredAsync() { - // Arrange — create a file with an extension not in the default list inside a spec folder + // Arrange — create a file with an extension not in the default list inside a spec directory string skillDir = Path.Combine(this._testRoot, "ext-skill"); string refsDir = Path.Combine(skillDir, "references"); Directory.CreateDirectory(refsDir); @@ -300,7 +300,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable [Fact] public async Task GetSkillsAsync_CustomResourceExtensions_UsedForDiscoveryAsync() { - // Arrange — use a source with custom extensions; files placed in spec folder + // Arrange — use a source with custom extensions; files placed in spec directory string skillDir = Path.Combine(this._testRoot, "custom-ext-skill"); string refsDir = Path.Combine(skillDir, "references"); Directory.CreateDirectory(refsDir); @@ -364,7 +364,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable [Fact] public async Task GetSkillsAsync_ResourceInSkillRoot_NotDiscoveredByDefaultAsync() { - // Arrange — resource files directly in the skill directory (not in a spec sub-folder) + // Arrange — resource files directly in the skill directory (not in a spec subdirectory) string skillDir = Path.Combine(this._testRoot, "root-resource-skill"); Directory.CreateDirectory(skillDir); File.WriteAllText(Path.Combine(skillDir, "guide.md"), "guide content"); @@ -377,15 +377,15 @@ public sealed class FileAgentSkillLoaderTests : IDisposable // Act var skills = await source.GetSkillsAsync(); - // Assert — root-level files are NOT discovered unless "." is in ResourceFolders + // Assert — root-level files are NOT discovered unless "." is in ResourceDirectories Assert.Single(skills); Assert.Empty(skills[0].Resources!); } [Fact] - public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredWhenRootFolderConfiguredAsync() + public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredWhenRootDirectoryConfiguredAsync() { - // Arrange — "." in ResourceFolders opts into root-level resource discovery + // Arrange — "." in ResourceDirectories opts into root-level resource discovery string skillDir = Path.Combine(this._testRoot, "root-opt-in-skill"); Directory.CreateDirectory(skillDir); File.WriteAllText(Path.Combine(skillDir, "guide.md"), "guide content"); @@ -394,7 +394,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable Path.Combine(skillDir, "SKILL.md"), "---\nname: root-opt-in-skill\ndescription: Root opt-in\n---\nBody."); var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, - new AgentFileSkillsSourceOptions { ResourceFolders = ["references", "assets", "."] }); + new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "assets", "."] }); // Act var skills = await source.GetSkillsAsync(); @@ -408,31 +408,31 @@ public sealed class FileAgentSkillLoaderTests : IDisposable } [Fact] - public async Task GetSkillsAsync_ResourceInNonSpecFolder_NotDiscoveredByDefaultAsync() + public async Task GetSkillsAsync_ResourceInNonSpecDirectory_NotDiscoveredByDefaultAsync() { - // Arrange — resource in a non-spec folder (neither references/ nor assets/) + // Arrange — resource in a non-spec directory (neither references/ nor assets/) string skillDir = Path.Combine(this._testRoot, "non-spec-skill"); string customDir = Path.Combine(skillDir, "docs"); Directory.CreateDirectory(customDir); File.WriteAllText(Path.Combine(customDir, "readme.md"), "docs content"); File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), - "---\nname: non-spec-skill\ndescription: Non-spec folder\n---\nBody."); + "---\nname: non-spec-skill\ndescription: Non-spec directory\n---\nBody."); var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act var skills = await source.GetSkillsAsync(); - // Assert — non-spec folders are not scanned by default + // Assert — non-spec directories are not scanned by default Assert.Single(skills); Assert.Empty(skills[0].Resources!); } [Fact] - public async Task GetSkillsAsync_CustomResourceFolders_ReplacesDefaultsAsync() + public async Task GetSkillsAsync_CustomResourceDirectories_ReplacesDefaultsAsync() { - // Arrange — custom ResourceFolders replaces the spec defaults - string skillDir = Path.Combine(this._testRoot, "custom-folder-skill"); + // Arrange — custom ResourceDirectories replaces the spec defaults + string skillDir = Path.Combine(this._testRoot, "custom-directory-skill"); string customDir = Path.Combine(skillDir, "docs"); string refsDir = Path.Combine(skillDir, "references"); Directory.CreateDirectory(customDir); @@ -441,9 +441,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText(Path.Combine(refsDir, "ref.md"), "ref content"); File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), - "---\nname: custom-folder-skill\ndescription: Custom folder\n---\nBody."); + "---\nname: custom-directory-skill\ndescription: Custom directory\n---\nBody."); var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, - new AgentFileSkillsSourceOptions { ResourceFolders = ["docs"] }); + new AgentFileSkillsSourceOptions { ResourceDirectories = ["docs"] }); // Act var skills = await source.GetSkillsAsync(); @@ -518,7 +518,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable [Fact] public async Task ReadSkillResourceAsync_ValidResource_ReturnsContentAsync() { - // Arrange — create a skill with a resource file discovered from the references folder + // Arrange — create a skill with a resource file discovered from the references directory string skillDir = this.CreateSkillDirectory("read-skill", "A skill", "See docs for details."); string refsDir = Path.Combine(skillDir, "references"); Directory.CreateDirectory(refsDir); @@ -614,12 +614,12 @@ public sealed class FileAgentSkillLoaderTests : IDisposable } [Fact] - public async Task GetSkillsAsync_SymlinkedResourceFolder_SkipsWithoutEnumeratingAsync() + public async Task GetSkillsAsync_SymlinkedResourceDirectory_SkipsWithoutEnumeratingAsync() { // Arrange — references/ is a symlink pointing outside the skill directory. // The directory-level check should skip it entirely (no file enumeration), // so even files with valid extensions in the target are not discovered. - string skillDir = Path.Combine(this._testRoot, "symlink-folder-skip"); + string skillDir = Path.Combine(this._testRoot, "symlink-directory-skip"); string assetsDir = Path.Combine(skillDir, "assets"); Directory.CreateDirectory(assetsDir); File.WriteAllText(Path.Combine(assetsDir, "legit.md"), "legit content"); @@ -642,21 +642,21 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), - "---\nname: symlink-folder-skip\ndescription: Symlinked folder skip\n---\nBody."); + "---\nname: symlink-directory-skip\ndescription: Symlinked directory skip\n---\nBody."); var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act var skills = await source.GetSkillsAsync(); - // Assert — only assets/legit.md is found; the symlinked references/ folder is skipped entirely - var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-folder-skip"); + // Assert — only assets/legit.md is found; the symlinked references/ directory is skipped entirely + var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-directory-skip"); Assert.NotNull(skill); Assert.Single(skill.Resources!); Assert.Equal("assets/legit.md", skill.Resources![0].Name); } [Fact] - public async Task GetSkillsAsync_SymlinkedScriptFolder_SkipsWithoutEnumeratingAsync() + public async Task GetSkillsAsync_SymlinkedScriptDirectory_SkipsWithoutEnumeratingAsync() { // Arrange — scripts/ is a symlink pointing outside the skill directory. // The directory-level check should skip it entirely. @@ -679,22 +679,22 @@ public sealed class FileAgentSkillLoaderTests : IDisposable File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), - "---\nname: symlink-script-skip\ndescription: Symlinked script folder\n---\nBody."); + "---\nname: symlink-script-skip\ndescription: Symlinked script directory\n---\nBody."); var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor); // Act var skills = await source.GetSkillsAsync(); - // Assert — skill loads but scripts from the symlinked folder are not discovered + // Assert — skill loads but scripts from the symlinked directory are not discovered var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-script-skip"); Assert.NotNull(skill); Assert.Empty(skill.Scripts!); } [Fact] - public async Task GetSkillsAsync_SymlinkedIntermediateSegment_SkipsCustomFolderAsync() + public async Task GetSkillsAsync_SymlinkedIntermediateSegment_SkipsCustomDirectoryAsync() { - // Arrange — custom resource folder "sub/resources" where "sub" is a symlink. + // Arrange — custom resource directory "sub/resources" where "sub" is a symlink. // The directory-level HasSymlinkInPath check should detect the intermediate symlink. string skillDir = Path.Combine(this._testRoot, "symlink-intermediate"); Directory.CreateDirectory(skillDir); @@ -720,12 +720,12 @@ public sealed class FileAgentSkillLoaderTests : IDisposable var source = new AgentFileSkillsSource( this._testRoot, s_noOpExecutor, - new AgentFileSkillsSourceOptions { ResourceFolders = ["sub/resources"] }); + new AgentFileSkillsSourceOptions { ResourceDirectories = ["sub/resources"] }); // Act var skills = await source.GetSkillsAsync(); - // Assert — the symlinked intermediate segment causes the folder to be skipped + // Assert — the symlinked intermediate segment causes the directory to be skipped var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-intermediate"); Assert.NotNull(skill); Assert.Empty(skill.Resources!); @@ -900,11 +900,11 @@ public sealed class FileAgentSkillLoaderTests : IDisposable [InlineData("sub/../escape")] [InlineData("/absolute")] [InlineData("\\absolute")] - public void Constructor_InvalidFolderName_SkipsInvalidFolders(string badFolder) + public void Constructor_InvalidDirectoryName_SkipsInvalidDirectories(string badDirectory) { - // Arrange & Act — invalid folders are skipped with a warning rather than throwing - var source1 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptFolders = [badFolder] }); - var source2 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceFolders = [badFolder] }); + // Arrange & Act — invalid directories are skipped with a warning rather than throwing + var source1 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptDirectories = [badDirectory] }); + var source2 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceDirectories = [badDirectory] }); // Assert Assert.NotNull(source1); @@ -915,54 +915,54 @@ public sealed class FileAgentSkillLoaderTests : IDisposable [InlineData(null)] [InlineData("")] [InlineData(" ")] - public void Constructor_NullOrWhitespaceFolderName_ThrowsArgumentException(string? badFolder) + public void Constructor_NullOrWhitespaceDirectoryName_ThrowsArgumentException(string? badDirectory) { // Arrange & Act & Assert — null/whitespace is a contract violation, not a config error - Assert.Throws(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptFolders = [badFolder!] })); - Assert.Throws(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceFolders = [badFolder!] })); + Assert.Throws(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptDirectories = [badDirectory!] })); + Assert.Throws(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceDirectories = [badDirectory!] })); } [Theory] [InlineData("scripts")] [InlineData("my-scripts")] - [InlineData("sub/folder")] + [InlineData("sub/directory")] [InlineData(".")] [InlineData("./scripts")] [InlineData("./scripts/f1")] [InlineData("my..scripts")] - public void Constructor_ValidFolderName_DoesNotThrow(string validFolder) + public void Constructor_ValidDirectoryName_DoesNotThrow(string validDirectory) { // Arrange & Act & Assert - var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptFolders = [validFolder] }); + var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptDirectories = [validDirectory] }); Assert.NotNull(source); } [Fact] - public async Task GetSkillsAsync_DuplicateFoldersAfterNormalization_NoDuplicateResourcesAsync() + public async Task GetSkillsAsync_DuplicateDirectoriesAfterNormalization_NoDuplicateResourcesAsync() { // Arrange — "references" and "./references" refer to the same directory; // after normalization they should be deduplicated so resources appear only once. - string skillDir = Path.Combine(this._testRoot, "dedup-folder-skill"); + string skillDir = Path.Combine(this._testRoot, "dedup-directory-skill"); string refsDir = Path.Combine(skillDir, "references"); Directory.CreateDirectory(refsDir); File.WriteAllText(Path.Combine(refsDir, "FAQ.md"), "FAQ content"); File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), - "---\nname: dedup-folder-skill\ndescription: Dedup test\n---\nBody."); + "---\nname: dedup-directory-skill\ndescription: Dedup test\n---\nBody."); var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, - new AgentFileSkillsSourceOptions { ResourceFolders = ["references", "./references"] }); + new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "./references"] }); // Act var skills = await source.GetSkillsAsync(); - // Assert — only one copy of the resource despite two equivalent folder entries + // Assert — only one copy of the resource despite two equivalent directory entries Assert.Single(skills); Assert.Single(skills[0].Resources!); Assert.Equal("references/FAQ.md", skills[0].Resources![0].Name); } [Fact] - public async Task GetSkillsAsync_TrailingSlashFolderNormalized_NoDuplicateResourcesAsync() + public async Task GetSkillsAsync_TrailingSlashDirectoryNormalized_NoDuplicateResourcesAsync() { // Arrange — "references/" should be normalized to "references" string skillDir = Path.Combine(this._testRoot, "trailing-slash-skill"); @@ -973,7 +973,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable Path.Combine(skillDir, "SKILL.md"), "---\nname: trailing-slash-skill\ndescription: Trailing slash test\n---\nBody."); var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, - new AgentFileSkillsSourceOptions { ResourceFolders = ["references", "references/"] }); + new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "references/"] }); // Act var skills = await source.GetSkillsAsync(); @@ -985,7 +985,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable } [Fact] - public async Task GetSkillsAsync_BackslashFolderNormalized_NoDuplicateScriptsAsync() + public async Task GetSkillsAsync_BackslashDirectoryNormalized_NoDuplicateScriptsAsync() { // Arrange — ".\\scripts" should be normalized to "scripts" string skillDir = Path.Combine(this._testRoot, "backslash-skill"); @@ -996,7 +996,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable Path.Combine(skillDir, "SKILL.md"), "---\nname: backslash-skill\ndescription: Backslash test\n---\nBody."); var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, - new AgentFileSkillsSourceOptions { ScriptFolders = ["scripts", ".\\scripts"] }); + new AgentFileSkillsSourceOptions { ScriptDirectories = ["scripts", ".\\scripts"] }); // Act var skills = await source.GetSkillsAsync(); @@ -1010,48 +1010,48 @@ public sealed class FileAgentSkillLoaderTests : IDisposable [Theory] [InlineData("./references")] [InlineData("./assets/docs")] - public async Task GetSkillsAsync_ResourceFolderWithDotSlashPrefix_DiscoversResourcesAsync(string folder) + public async Task GetSkillsAsync_ResourceDirectoryWithDotSlashPrefix_DiscoversResourcesAsync(string directory) { // Arrange — "./references" and "./assets/docs" are equivalent to "references" and "assets/docs"; // the leading "./" is transparently normalized by Path.GetFullPath during file enumeration. - string folderWithoutDotSlash = folder.Substring(2); // strip "./" + string directoryWithoutDotSlash = directory.Substring(2); // strip "./" string skillDir = Path.Combine(this._testRoot, "dotslash-res-skill"); - string targetDir = Path.Combine(skillDir, folderWithoutDotSlash.Replace('/', Path.DirectorySeparatorChar)); + string targetDir = Path.Combine(skillDir, directoryWithoutDotSlash.Replace('/', Path.DirectorySeparatorChar)); Directory.CreateDirectory(targetDir); File.WriteAllText(Path.Combine(targetDir, "data.json"), "{}"); File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), "---\nname: dotslash-res-skill\ndescription: Dot-slash prefix\n---\nBody."); var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, - new AgentFileSkillsSourceOptions { ResourceFolders = [folder] }); + new AgentFileSkillsSourceOptions { ResourceDirectories = [directory] }); // Act var skills = await source.GetSkillsAsync(); - // Assert — the resource is discovered with a name identical to using the folder without "./" + // Assert — the resource is discovered with a name identical to using the directory without "./" Assert.Single(skills); Assert.Single(skills[0].Resources!); - Assert.Equal($"{folderWithoutDotSlash}/data.json", skills[0].Resources![0].Name); + Assert.Equal($"{directoryWithoutDotSlash}/data.json", skills[0].Resources![0].Name); } [Fact] - public async Task GetSkillsAsync_ResourceFoldersWithNestedPath_DiscoversResourcesAsync() + public async Task GetSkillsAsync_ResourceDirectoriesWithNestedPath_DiscoversResourcesAsync() { - // Arrange — ResourceFolders configured with a multi-segment relative path (f1/f2/f3) - string skillDir = Path.Combine(this._testRoot, "nested-folder-skill"); + // Arrange — ResourceDirectories configured with a multi-segment relative path (f1/f2/f3) + string skillDir = Path.Combine(this._testRoot, "nested-directory-skill"); string nestedDir = Path.Combine(skillDir, "f1", "f2", "f3"); Directory.CreateDirectory(nestedDir); File.WriteAllText(Path.Combine(nestedDir, "data.json"), "{}"); File.WriteAllText( Path.Combine(skillDir, "SKILL.md"), - "---\nname: nested-folder-skill\ndescription: Nested folder\n---\nBody."); + "---\nname: nested-directory-skill\ndescription: Nested directory\n---\nBody."); var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, - new AgentFileSkillsSourceOptions { ResourceFolders = ["f1/f2/f3"] }); + new AgentFileSkillsSourceOptions { ResourceDirectories = ["f1/f2/f3"] }); // Act var skills = await source.GetSkillsAsync(); - // Assert — resource file inside the deeply nested folder is discovered + // Assert — resource file inside the deeply nested directory is discovered Assert.Single(skills); var skill = skills[0]; Assert.Single(skill.Resources!); @@ -1107,9 +1107,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable } [Fact] - public async Task GetSkillsAsync_ScriptInSkillRoot_DiscoveredWhenRootFolderConfiguredAsync() + public async Task GetSkillsAsync_ScriptInSkillRoot_DiscoveredWhenRootDirectoryConfiguredAsync() { - // Arrange — script file directly in the skill directory with ScriptFolders = ["."] + // Arrange — script file directly in the skill directory with ScriptDirectories = ["."] string skillDir = Path.Combine(this._testRoot, "root-script-skill"); Directory.CreateDirectory(skillDir); File.WriteAllText(Path.Combine(skillDir, "run.py"), "print('hello')"); @@ -1117,7 +1117,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable Path.Combine(skillDir, "SKILL.md"), "---\nname: root-script-skill\ndescription: Root script\n---\nBody."); var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, - new AgentFileSkillsSourceOptions { ScriptFolders = ["."] }); + new AgentFileSkillsSourceOptions { ScriptDirectories = ["."] }); // Act var skills = await source.GetSkillsAsync(); @@ -1131,7 +1131,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable #if NET [Fact] - public async Task GetSkillsAsync_SymlinkedFileInRealFolder_SkipsSymlinkedFileAsync() + public async Task GetSkillsAsync_SymlinkedFileInRealDirectory_SkipsSymlinkedFileAsync() { // Arrange — references/ is a real directory, but one file inside it is a symlink // pointing outside the skill directory. The per-file symlink check should skip it. From 3e864cdb4c6031cf93096fa6af4d927b31126d8a Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:28:00 +0100 Subject: [PATCH 17/22] .NET: Update version to 1.1.0 (#5204) * Update version to 1.1.0 * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- dotnet/nuget/nuget-package.props | 16 +- .../CompatibilitySuppressions.xml | 284 ------------------ .../CompatibilitySuppressions.xml | 39 --- .../CompatibilitySuppressions.xml | 39 --- .../CompatibilitySuppressions.xml | 105 ------- 5 files changed, 8 insertions(+), 475 deletions(-) delete mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry/CompatibilitySuppressions.xml delete mode 100644 dotnet/src/Microsoft.Agents.AI.OpenAI/CompatibilitySuppressions.xml delete mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/CompatibilitySuppressions.xml diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props index c64f43949f..9d91eebf28 100644 --- a/dotnet/nuget/nuget-package.props +++ b/dotnet/nuget/nuget-package.props @@ -1,21 +1,21 @@ - 1.0.0 - 6 + 1.1.0 + 1 $(VersionPrefix)-rc$(RCNumber) - $(VersionPrefix)-$(VersionSuffix).260402.1 - $(VersionPrefix)-preview.260402.1 + $(VersionPrefix)-$(VersionSuffix).260410.1 + $(VersionPrefix)-preview.260410.1 $(VersionPrefix) - 1.0.0 + 1.1.0 Debug;Release;Publish true - 1.0.0-rc5 - - true + 1.0.0 + + true $(NoWarn);CP0003 diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/CompatibilitySuppressions.xml b/dotnet/src/Microsoft.Agents.AI.Foundry/CompatibilitySuppressions.xml deleted file mode 100644 index 79cd4b890d..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/CompatibilitySuppressions.xml +++ /dev/null @@ -1,284 +0,0 @@ - - - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentRecord,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider) - lib/net10.0/Microsoft.Agents.AI.AzureAI.dll - lib/net10.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentReference,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider) - lib/net10.0/Microsoft.Agents.AI.AzureAI.dll - lib/net10.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentVersion,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider) - lib/net10.0/Microsoft.Agents.AI.AzureAI.dll - lib/net10.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Azure.AI.Projects.AgentVersionCreationOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.Threading.CancellationToken) - lib/net10.0/Microsoft.Agents.AI.AzureAI.dll - lib/net10.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/net10.0/Microsoft.Agents.AI.AzureAI.dll - lib/net10.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/net10.0/Microsoft.Agents.AI.AzureAI.dll - lib/net10.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/net10.0/Microsoft.Agents.AI.AzureAI.dll - lib/net10.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/net10.0/Microsoft.Agents.AI.AzureAI.dll - lib/net10.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentRecord,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider) - lib/net472/Microsoft.Agents.AI.AzureAI.dll - lib/net472/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentReference,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider) - lib/net472/Microsoft.Agents.AI.AzureAI.dll - lib/net472/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentVersion,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider) - lib/net472/Microsoft.Agents.AI.AzureAI.dll - lib/net472/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Azure.AI.Projects.AgentVersionCreationOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.Threading.CancellationToken) - lib/net472/Microsoft.Agents.AI.AzureAI.dll - lib/net472/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/net472/Microsoft.Agents.AI.AzureAI.dll - lib/net472/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/net472/Microsoft.Agents.AI.AzureAI.dll - lib/net472/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/net472/Microsoft.Agents.AI.AzureAI.dll - lib/net472/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/net472/Microsoft.Agents.AI.AzureAI.dll - lib/net472/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentRecord,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider) - lib/net8.0/Microsoft.Agents.AI.AzureAI.dll - lib/net8.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentReference,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider) - lib/net8.0/Microsoft.Agents.AI.AzureAI.dll - lib/net8.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentVersion,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider) - lib/net8.0/Microsoft.Agents.AI.AzureAI.dll - lib/net8.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Azure.AI.Projects.AgentVersionCreationOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.Threading.CancellationToken) - lib/net8.0/Microsoft.Agents.AI.AzureAI.dll - lib/net8.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/net8.0/Microsoft.Agents.AI.AzureAI.dll - lib/net8.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/net8.0/Microsoft.Agents.AI.AzureAI.dll - lib/net8.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/net8.0/Microsoft.Agents.AI.AzureAI.dll - lib/net8.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/net8.0/Microsoft.Agents.AI.AzureAI.dll - lib/net8.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentRecord,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider) - lib/net9.0/Microsoft.Agents.AI.AzureAI.dll - lib/net9.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentReference,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider) - lib/net9.0/Microsoft.Agents.AI.AzureAI.dll - lib/net9.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentVersion,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider) - lib/net9.0/Microsoft.Agents.AI.AzureAI.dll - lib/net9.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Azure.AI.Projects.AgentVersionCreationOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.Threading.CancellationToken) - lib/net9.0/Microsoft.Agents.AI.AzureAI.dll - lib/net9.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/net9.0/Microsoft.Agents.AI.AzureAI.dll - lib/net9.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/net9.0/Microsoft.Agents.AI.AzureAI.dll - lib/net9.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/net9.0/Microsoft.Agents.AI.AzureAI.dll - lib/net9.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/net9.0/Microsoft.Agents.AI.AzureAI.dll - lib/net9.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentRecord,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider) - lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll - lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentReference,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider) - lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll - lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentVersion,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider) - lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll - lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Azure.AI.Projects.AgentVersionCreationOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.Threading.CancellationToken) - lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll - lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll - lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll - lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll - lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll - true - - - CP0002 - M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken) - lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll - lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll - true - - \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.OpenAI/CompatibilitySuppressions.xml b/dotnet/src/Microsoft.Agents.AI.OpenAI/CompatibilitySuppressions.xml deleted file mode 100644 index 1ebb184fbc..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.OpenAI/CompatibilitySuppressions.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - CP0001 - T:OpenAI.Assistants.OpenAIAssistantClientExtensions - lib/net10.0/Microsoft.Agents.AI.OpenAI.dll - lib/net10.0/Microsoft.Agents.AI.OpenAI.dll - true - - - CP0001 - T:OpenAI.Assistants.OpenAIAssistantClientExtensions - lib/net472/Microsoft.Agents.AI.OpenAI.dll - lib/net472/Microsoft.Agents.AI.OpenAI.dll - true - - - CP0001 - T:OpenAI.Assistants.OpenAIAssistantClientExtensions - lib/net8.0/Microsoft.Agents.AI.OpenAI.dll - lib/net8.0/Microsoft.Agents.AI.OpenAI.dll - true - - - CP0001 - T:OpenAI.Assistants.OpenAIAssistantClientExtensions - lib/net9.0/Microsoft.Agents.AI.OpenAI.dll - lib/net9.0/Microsoft.Agents.AI.OpenAI.dll - true - - - CP0001 - T:OpenAI.Assistants.OpenAIAssistantClientExtensions - lib/netstandard2.0/Microsoft.Agents.AI.OpenAI.dll - lib/netstandard2.0/Microsoft.Agents.AI.OpenAI.dll - true - - \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/CompatibilitySuppressions.xml b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/CompatibilitySuppressions.xml deleted file mode 100644 index fbf1db84c7..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/CompatibilitySuppressions.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - CP0002 - M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions - lib/net10.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll - lib/net10.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll - true - - - CP0002 - M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions - lib/net472/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll - lib/net472/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll - true - - - CP0002 - M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions - lib/net8.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll - lib/net8.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll - true - - - CP0002 - M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions - lib/net9.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll - lib/net9.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll - true - - - CP0002 - M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions - lib/netstandard2.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll - lib/netstandard2.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll - true - - \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI/CompatibilitySuppressions.xml b/dotnet/src/Microsoft.Agents.AI/CompatibilitySuppressions.xml index daad8d79a1..a8268863bd 100644 --- a/dotnet/src/Microsoft.Agents.AI/CompatibilitySuppressions.xml +++ b/dotnet/src/Microsoft.Agents.AI/CompatibilitySuppressions.xml @@ -43,27 +43,6 @@ lib/net10.0/Microsoft.Agents.AI.dll true - - CP0002 - M:Microsoft.Agents.AI.ChatClientAgentOptions.get_SimulateServiceStoredChatHistory - lib/net10.0/Microsoft.Agents.AI.dll - lib/net10.0/Microsoft.Agents.AI.dll - true - - - CP0002 - M:Microsoft.Agents.AI.ChatClientAgentOptions.set_SimulateServiceStoredChatHistory(System.Boolean) - lib/net10.0/Microsoft.Agents.AI.dll - lib/net10.0/Microsoft.Agents.AI.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ChatClientBuilderExtensions.UseServiceStoredChatHistorySimulation(Microsoft.Extensions.AI.ChatClientBuilder) - lib/net10.0/Microsoft.Agents.AI.dll - lib/net10.0/Microsoft.Agents.AI.dll - true - CP0002 M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String) @@ -106,27 +85,6 @@ lib/net472/Microsoft.Agents.AI.dll true - - CP0002 - M:Microsoft.Agents.AI.ChatClientAgentOptions.get_SimulateServiceStoredChatHistory - lib/net472/Microsoft.Agents.AI.dll - lib/net472/Microsoft.Agents.AI.dll - true - - - CP0002 - M:Microsoft.Agents.AI.ChatClientAgentOptions.set_SimulateServiceStoredChatHistory(System.Boolean) - lib/net472/Microsoft.Agents.AI.dll - lib/net472/Microsoft.Agents.AI.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ChatClientBuilderExtensions.UseServiceStoredChatHistorySimulation(Microsoft.Extensions.AI.ChatClientBuilder) - lib/net472/Microsoft.Agents.AI.dll - lib/net472/Microsoft.Agents.AI.dll - true - CP0002 M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String) @@ -169,27 +127,6 @@ lib/net8.0/Microsoft.Agents.AI.dll true - - CP0002 - M:Microsoft.Agents.AI.ChatClientAgentOptions.get_SimulateServiceStoredChatHistory - lib/net8.0/Microsoft.Agents.AI.dll - lib/net8.0/Microsoft.Agents.AI.dll - true - - - CP0002 - M:Microsoft.Agents.AI.ChatClientAgentOptions.set_SimulateServiceStoredChatHistory(System.Boolean) - lib/net8.0/Microsoft.Agents.AI.dll - lib/net8.0/Microsoft.Agents.AI.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ChatClientBuilderExtensions.UseServiceStoredChatHistorySimulation(Microsoft.Extensions.AI.ChatClientBuilder) - lib/net8.0/Microsoft.Agents.AI.dll - lib/net8.0/Microsoft.Agents.AI.dll - true - CP0002 M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String) @@ -232,27 +169,6 @@ lib/net9.0/Microsoft.Agents.AI.dll true - - CP0002 - M:Microsoft.Agents.AI.ChatClientAgentOptions.get_SimulateServiceStoredChatHistory - lib/net9.0/Microsoft.Agents.AI.dll - lib/net9.0/Microsoft.Agents.AI.dll - true - - - CP0002 - M:Microsoft.Agents.AI.ChatClientAgentOptions.set_SimulateServiceStoredChatHistory(System.Boolean) - lib/net9.0/Microsoft.Agents.AI.dll - lib/net9.0/Microsoft.Agents.AI.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ChatClientBuilderExtensions.UseServiceStoredChatHistorySimulation(Microsoft.Extensions.AI.ChatClientBuilder) - lib/net9.0/Microsoft.Agents.AI.dll - lib/net9.0/Microsoft.Agents.AI.dll - true - CP0002 M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String) @@ -295,25 +211,4 @@ lib/netstandard2.0/Microsoft.Agents.AI.dll true - - CP0002 - M:Microsoft.Agents.AI.ChatClientAgentOptions.get_SimulateServiceStoredChatHistory - lib/netstandard2.0/Microsoft.Agents.AI.dll - lib/netstandard2.0/Microsoft.Agents.AI.dll - true - - - CP0002 - M:Microsoft.Agents.AI.ChatClientAgentOptions.set_SimulateServiceStoredChatHistory(System.Boolean) - lib/netstandard2.0/Microsoft.Agents.AI.dll - lib/netstandard2.0/Microsoft.Agents.AI.dll - true - - - CP0002 - M:Microsoft.Extensions.AI.ChatClientBuilderExtensions.UseServiceStoredChatHistorySimulation(Microsoft.Extensions.AI.ChatClientBuilder) - lib/netstandard2.0/Microsoft.Agents.AI.dll - lib/netstandard2.0/Microsoft.Agents.AI.dll - true - \ No newline at end of file From 39b560f83cf6effcdbff0b69e96f830f06ab1432 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:00:31 +0100 Subject: [PATCH 18/22] Add missing path to verify-samples run checkout (#5194) --- .github/workflows/dotnet-verify-samples.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dotnet-verify-samples.yml b/.github/workflows/dotnet-verify-samples.yml index 40ee3124b4..7cb0b9636f 100644 --- a/.github/workflows/dotnet-verify-samples.yml +++ b/.github/workflows/dotnet-verify-samples.yml @@ -48,7 +48,8 @@ jobs: . .github dotnet - workflow-samples + python + declarative-agents - name: Setup dotnet uses: actions/setup-dotnet@v5.2.0 From 76fe7319e066b56bf3818c67e2ddadf7ccefe3cf Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Mon, 13 Apr 2026 10:59:17 -0400 Subject: [PATCH 19/22] .NET: feat: Refactor Handoff Orchestration and add HITL support (#5174) * feat: Refactor Handoff Orchestration and add HITL support * Change HandoffAgentExecutor to use factory-based instantiation * Extract shared request collection logic in AIAgentUnservicedRequestsCollector * Refactor HandoffAgentExecutor to use the "ContinueTurn" pattern as in AIAgentHostExecutor * fix: Remove '$' from exception strings --- .../HandoffWorkflowBuilder.cs | 77 +++- .../Specialized/AIAgentHostExecutor.cs | 68 +-- .../AIAgentUnservicedRequestsCollector.cs | 78 ++++ .../Specialized/HandoffAgentExecutor.cs | 388 +++++++++++++----- ...fsEndExecutor.cs => HandoffEndExecutor.cs} | 8 +- ...artExecutor.cs => HandoffStartExecutor.cs} | 16 +- .../Specialized/HandoffState.cs | 4 +- .../StatefulExecutor.cs | 32 +- .../AgentWorkflowBuilderTests.cs | 284 ++++++++++++- .../HandoffAgentExecutorTests.cs | 4 +- 10 files changed, 758 insertions(+), 201 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentUnservicedRequestsCollector.cs rename dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/{HandoffsEndExecutor.cs => HandoffEndExecutor.cs} (86%) rename dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/{HandoffsStartExecutor.cs => HandoffStartExecutor.cs} (71%) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs index 4e9f201053..4c93414c63 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs @@ -8,6 +8,10 @@ using Microsoft.Agents.AI.Workflows.Specialized; using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; +using ExecutorFactoryFunc = System.Func, + string, + System.Threading.Tasks.ValueTask>; + namespace Microsoft.Agents.AI.Workflows; internal static class DiagnosticConstants @@ -233,6 +237,57 @@ public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkfl return (TBuilder)this; } + private Dictionary CreateExecutorBindings(WorkflowBuilder builder) + { + HandoffAgentExecutorOptions options = new(this.HandoffInstructions, + this._emitAgentResponseEvents, + this._emitAgentResponseUpdateEvents, + this._toolCallFilteringBehavior); + + // There are two types of ids being used in this method, and it is critical that we are clear about + // which one we are using, and where. + // AgentId...: comes from AIAgent.Id, is often an unreadable machine identifier (e.g. a Guid), and is used to address + // the handoffs + // ExecutorId: uses AIAgent.GetDescriptiveId() to use a friendlier name in telemetry, and is used for ExecutorBinding, + // which are subsequently used in building the workflow + + // The outgoing dictionary maps from AgentId => ExecutorBinding + return this._allAgents.ToDictionary(keySelector: a => a.Id, elementSelector: CreateFactoryBinding); + + ExecutorBinding CreateFactoryBinding(AIAgent agent) + { + if (!this._targets.TryGetValue(agent, out HashSet? handoffs)) + { + handoffs = new(); + } + + // Use the ExecutorId as the placeholder id for a (possibly) future-bound factory + builder.AddSwitch(HandoffAgentExecutor.IdFor(agent), (SwitchBuilder sb) => + { + foreach (HandoffTarget handoff in handoffs) + { + sb.AddCase(state => state?.RequestedHandoffTargetAgentId == handoff.Target.Id, // Use AgentId for target matching + HandoffAgentExecutor.IdFor(handoff.Target)); // Use ExecutorId in for routing at the workflow level + } + + sb.WithDefault(HandoffEndExecutor.ExecutorId); + }); + + ExecutorFactoryFunc factory = + (config, sessionId) => new( + new HandoffAgentExecutor(agent, + handoffs, + options)); + + // Make sure to use ExecutorId when binding the executor, not AgentId + ExecutorBinding binding = factory.BindExecutor(HandoffAgentExecutor.IdFor(agent)); + + builder.BindExecutor(binding); + + return binding; + } + } + /// /// Builds a composed of agents that operate via handoffs, with the next /// agent to process messages selected by the current agent. @@ -240,17 +295,12 @@ public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkfl /// The workflow built based on the handoffs in the builder. public Workflow Build() { - HandoffsStartExecutor start = new(this._returnToPrevious); - HandoffsEndExecutor end = new(this._returnToPrevious); + HandoffStartExecutor start = new(this._returnToPrevious); + HandoffEndExecutor end = new(this._returnToPrevious); WorkflowBuilder builder = new(start); - HandoffAgentExecutorOptions options = new(this.HandoffInstructions, - this._emitAgentResponseEvents, - this._emitAgentResponseUpdateEvents, - this._toolCallFilteringBehavior); - - // Create an AgentExecutor for each agent. - Dictionary executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, options)); + // Create an factory-based ExecutorBinding for each agent. + Dictionary executors = this.CreateExecutorBindings(builder); // Connect the start executor to the initial agent (or use dynamic routing when ReturnToPrevious is enabled). if (this._returnToPrevious) @@ -263,7 +313,7 @@ public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkfl if (agent.Id != initialAgentId) { string agentId = agent.Id; - sb.AddCase(state => state?.CurrentAgentId == agentId, executors[agentId]); + sb.AddCase(state => state?.PreviousAgentId == agentId, executors[agentId]); } } @@ -275,13 +325,6 @@ public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkfl builder.AddEdge(start, executors[this._initialAgent.Id]); } - // Initialize each executor with its handoff targets to the other executors. - foreach (var agent in this._allAgents) - { - executors[agent.Id].Initialize(builder, end, executors, - this._targets.TryGetValue(agent, out HashSet? targets) ? targets : []); - } - // Build the workflow. return builder.WithOutputFrom(end).Build(); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs index 3f3d83fbee..b7d2911537 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs @@ -19,6 +19,9 @@ internal static class TurnExtensions public static bool ShouldEmitStreamingEvents(bool? turnTokenSetting, bool? agentSetting) => turnTokenSetting ?? agentSetting ?? false; + + public static bool ShouldEmitStreamingEvents(this HandoffState handoffState, bool? agentSetting) + => handoffState.TurnToken.ShouldEmitStreamingEvents(agentSetting); } internal sealed class AIAgentHostExecutor : ChatProtocolExecutor @@ -81,7 +84,11 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor // resumes can be processed in one invocation. return this.ProcessTurnMessagesAsync(async (pendingMessages, ctx, ct) => { - pendingMessages.Add(new ChatMessage(ChatRole.User, [response])); + pendingMessages.Add(new ChatMessage(ChatRole.User, [response]) + { + CreatedAt = DateTimeOffset.UtcNow, + MessageId = Guid.NewGuid().ToString("N"), + }); await this.ContinueTurnAsync(pendingMessages, ctx, this._currentTurnEmitEvents ?? false, ct).ConfigureAwait(false); @@ -104,7 +111,12 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor // resumes can be processed in one invocation. return this.ProcessTurnMessagesAsync(async (pendingMessages, ctx, ct) => { - pendingMessages.Add(new ChatMessage(ChatRole.Tool, [result])); + pendingMessages.Add(new ChatMessage(ChatRole.Tool, [result]) + { + AuthorName = this._agent.Name ?? this._agent.Id, + CreatedAt = DateTimeOffset.UtcNow, + MessageId = Guid.NewGuid().ToString("N"), + }); await this.ContinueTurnAsync(pendingMessages, ctx, this._currentTurnEmitEvents ?? false, ct).ConfigureAwait(false); @@ -186,16 +198,13 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor TurnExtensions.ShouldEmitStreamingEvents(turnTokenSetting: emitEvents, this._options.EmitAgentUpdateEvents), cancellationToken); - private async ValueTask InvokeAgentAsync(IEnumerable messages, IWorkflowContext context, bool emitEvents, CancellationToken cancellationToken = default) + private async ValueTask InvokeAgentAsync(IEnumerable messages, IWorkflowContext context, bool emitUpdateEvents, CancellationToken cancellationToken = default) { -#pragma warning disable MEAI001 - Dictionary userInputRequests = new(); - Dictionary functionCalls = new(); AgentResponse response; + AIAgentUnservicedRequestsCollector collector = new(this._userInputHandler, this._functionCallHandler); - if (emitEvents) + if (emitUpdateEvents) { -#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. // Run the agent in streaming mode only when agent run update events are to be emitted. IAsyncEnumerable agentStream = this._agent.RunStreamingAsync( messages, @@ -206,7 +215,7 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false)) { await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false); - ExtractUnservicedRequests(update.Contents); + collector.ProcessAgentResponseUpdate(update); updates.Add(update); } @@ -220,7 +229,7 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor cancellationToken: cancellationToken) .ConfigureAwait(false); - ExtractUnservicedRequests(response.Messages.SelectMany(message => message.Contents)); + collector.ProcessAgentResponse(response); } if (this._options.EmitAgentResponseEvents) @@ -228,45 +237,8 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false); } - if (userInputRequests.Count > 0 || functionCalls.Count > 0) - { - Task userInputTask = this._userInputHandler?.ProcessRequestContentsAsync(userInputRequests, context, cancellationToken) ?? Task.CompletedTask; - Task functionCallTask = this._functionCallHandler?.ProcessRequestContentsAsync(functionCalls, context, cancellationToken) ?? Task.CompletedTask; - - await Task.WhenAll(userInputTask, functionCallTask) - .ConfigureAwait(false); - } + await collector.SubmitAsync(context, cancellationToken).ConfigureAwait(false); return response; - - void ExtractUnservicedRequests(IEnumerable contents) - { - foreach (AIContent content in contents) - { - if (content is ToolApprovalRequestContent userInputRequest) - { - // It is an error to simultaneously have multiple outstanding user input requests with the same ID. - userInputRequests.Add(userInputRequest.RequestId, userInputRequest); - } - else if (content is ToolApprovalResponseContent userInputResponse) - { - // If the set of messages somehow already has a corresponding user input response, remove it. - _ = userInputRequests.Remove(userInputResponse.RequestId); - } - else if (content is FunctionCallContent functionCall) - { - // For function calls, we emit an event to notify the workflow. - // - // possibility 1: this will be handled inline by the agent abstraction - // possibility 2: this will not be handled inline by the agent abstraction - functionCalls.Add(functionCall.CallId, functionCall); - } - else if (content is FunctionResultContent functionResult) - { - _ = functionCalls.Remove(functionResult.CallId); - } - } - } -#pragma warning restore MEAI001 } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentUnservicedRequestsCollector.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentUnservicedRequestsCollector.cs new file mode 100644 index 0000000000..7e4f8c8c9d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentUnservicedRequestsCollector.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.Specialized; + +internal sealed class AIAgentUnservicedRequestsCollector(AIContentExternalHandler? userInputHandler, + AIContentExternalHandler? functionCallHandler) +{ + private readonly Dictionary _userInputRequests = []; + private readonly Dictionary _functionCalls = []; + + public Task SubmitAsync(IWorkflowContext context, CancellationToken cancellationToken) + { + Task userInputTask = userInputHandler != null && this._userInputRequests.Count > 0 + ? userInputHandler.ProcessRequestContentsAsync(this._userInputRequests, context, cancellationToken) + : Task.CompletedTask; + + Task functionCallTask = functionCallHandler != null && this._functionCalls.Count > 0 + ? functionCallHandler.ProcessRequestContentsAsync(this._functionCalls, context, cancellationToken) + : Task.CompletedTask; + + return Task.WhenAll(userInputTask, functionCallTask); + } + + public void ProcessAgentResponseUpdate(AgentResponseUpdate update, Func? functionCallFilter = null) + => this.ProcessAIContents(update.Contents, functionCallFilter); + + public void ProcessAgentResponse(AgentResponse response) + => this.ProcessAIContents(response.Messages.SelectMany(message => message.Contents)); + + public void ProcessAIContents(IEnumerable contents, Func? functionCallFilter = null) + { + foreach (AIContent content in contents) + { + if (content is ToolApprovalRequestContent userInputRequest) + { + if (this._userInputRequests.ContainsKey(userInputRequest.RequestId)) + { + throw new InvalidOperationException($"ToolApprovalRequestContent with duplicate RequestId: {userInputRequest.RequestId}"); + } + + // It is an error to simultaneously have multiple outstanding user input requests with the same ID. + this._userInputRequests.Add(userInputRequest.RequestId, userInputRequest); + } + else if (content is ToolApprovalResponseContent userInputResponse) + { + // If the set of messages somehow already has a corresponding user input response, remove it. + _ = this._userInputRequests.Remove(userInputResponse.RequestId); + } + else if (content is FunctionCallContent functionCall) + { + // For function calls, we emit an event to notify the workflow. + // + // possibility 1: this will be handled inline by the agent abstraction + // possibility 2: this will not be handled inline by the agent abstraction + if (functionCallFilter == null || functionCallFilter(functionCall)) + { + if (this._functionCalls.ContainsKey(functionCall.CallId)) + { + throw new InvalidOperationException($"FunctionCallContent with duplicate CallId: {functionCall.CallId}"); + } + + this._functionCalls.Add(functionCall.CallId, functionCall); + } + } + else if (content is FunctionResultContent functionResult) + { + _ = this._functionCalls.Remove(functionResult.CallId); + } + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs index e885b894fd..eac2eb5687 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; using System.ComponentModel; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text.Json; @@ -166,128 +165,331 @@ internal sealed class HandoffMessagesFilter } } +internal struct AgentInvocationResult(AgentResponse agentResponse, string? handoffTargetId) +{ + public AgentResponse Response => agentResponse; + + public string? HandoffTargetId => handoffTargetId; + + [MemberNotNullWhen(true, nameof(HandoffTargetId))] + public bool IsHandoffRequested => this.HandoffTargetId != null; +} + +internal record HandoffAgentHostState(HandoffState? CurrentTurnState, List FilteredIncomingMessages, List TurnMessages) +{ + public HandoffState PrepareHandoff(AgentInvocationResult invocationResult, string currentAgentId) + { + if (this.CurrentTurnState == null) + { + throw new InvalidOperationException("Cannot create a handoff request: Out of turn."); + } + + IEnumerable allMessages = [.. this.CurrentTurnState.Messages, .. this.TurnMessages, .. invocationResult.Response.Messages]; + + return new(this.CurrentTurnState.TurnToken, invocationResult.HandoffTargetId, allMessages.ToList(), currentAgentId); + } +} + /// Executor used to represent an agent in a handoffs workflow, responding to events. [Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)] -internal sealed class HandoffAgentExecutor( - AIAgent agent, - HandoffAgentExecutorOptions options) : Executor(agent.GetDescriptiveId(), declareCrossRunShareable: true), IResettableExecutor +internal sealed class HandoffAgentExecutor : + StatefulExecutor { private static readonly JsonElement s_handoffSchema = AIFunctionFactory.Create( ([Description("The reason for the handoff")] string? reasonForHandoff) => { }).JsonSchema; - private readonly AIAgent _agent = agent; + public static string IdFor(AIAgent agent) => agent.GetDescriptiveId(); + + private readonly AIAgent _agent; + private readonly ChatClientAgentRunOptions? _agentOptions; + + private readonly HandoffAgentExecutorOptions _options; + private readonly HashSet _handoffFunctionNames = []; private readonly Dictionary _handoffFunctionToAgentId = []; - private ChatClientAgentRunOptions? _agentOptions; - public void Initialize( - WorkflowBuilder builder, - Executor end, - Dictionary executors, - HashSet handoffs) => - builder.AddSwitch(this, sb => - { - if (handoffs.Count != 0) - { - Debug.Assert(this._agentOptions is null); - this._agentOptions = new() - { - ChatOptions = new() - { - AllowMultipleToolCalls = false, - Instructions = options.HandoffInstructions, - Tools = [], - }, - }; + private static HandoffAgentHostState InitialStateFactory() => new(null, [], []); - int index = 0; - foreach (HandoffTarget handoff in handoffs) - { - index++; - var handoffFunc = AIFunctionFactory.CreateDeclaration($"{HandoffWorkflowBuilder.FunctionPrefix}{index}", handoff.Reason, s_handoffSchema); - - this._handoffFunctionNames.Add(handoffFunc.Name); - this._handoffFunctionToAgentId[handoffFunc.Name] = handoff.Target.Id; - - this._agentOptions.ChatOptions.Tools.Add(handoffFunc); - - sb.AddCase(state => state?.InvokedHandoff == handoffFunc.Name, executors[handoff.Target.Id]); - } - } - - sb.WithDefault(end); - }); - - public override async ValueTask HandleAsync(HandoffState message, IWorkflowContext context, CancellationToken cancellationToken = default) + public HandoffAgentExecutor(AIAgent agent, HashSet handoffs, HandoffAgentExecutorOptions options) + : base(IdFor(agent), InitialStateFactory) { - string? requestedHandoff = null; - List updates = []; - List allMessages = message.Messages; + this._agent = agent; + this._options = options; - List? roleChanges = allMessages.ChangeAssistantToUserForOtherParticipants(this._agent.Name ?? this._agent.Id); + this._agentOptions = CreateAgentHandoffContext(this._options.HandoffInstructions, handoffs, this._handoffFunctionNames, this._handoffFunctionToAgentId); + } - // If a handoff was invoked by a previous agent, filter out the handoff function - // call and tool result messages before sending to the underlying agent. These - // are internal workflow mechanics that confuse the target model into ignoring the - // original user question. - HandoffMessagesFilter handoffMessagesFilter = new(options.ToolCallFilteringBehavior); - IEnumerable messagesForAgent = message.InvokedHandoff is not null - ? handoffMessagesFilter.FilterMessages(allMessages) - : allMessages; + private static ChatClientAgentRunOptions? CreateAgentHandoffContext(string? handoffInstructions, HashSet handoffs, HashSet functionNames, Dictionary functionToAgentId) + { + ChatClientAgentRunOptions? result = null; - await foreach (var update in this._agent.RunStreamingAsync(messagesForAgent, - options: this._agentOptions, - cancellationToken: cancellationToken) - .ConfigureAwait(false)) + if (handoffs.Count != 0) { - await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false); - - foreach (var fcc in update.Contents.OfType() - .Where(fcc => this._handoffFunctionNames.Contains(fcc.Name))) + result = new() { - requestedHandoff = fcc.Name; - await AddUpdateAsync( - new AgentResponseUpdate - { - AgentId = this._agent.Id, - AuthorName = this._agent.Name ?? this._agent.Id, - Contents = [new FunctionResultContent(fcc.CallId, "Transferred.")], - CreatedAt = DateTimeOffset.UtcNow, - MessageId = Guid.NewGuid().ToString("N"), - Role = ChatRole.Tool, - }, - cancellationToken - ) - .ConfigureAwait(false); + ChatOptions = new() + { + AllowMultipleToolCalls = false, + Instructions = handoffInstructions, + Tools = [], + }, + }; + + int index = 0; + foreach (HandoffTarget handoff in handoffs) + { + index++; + var handoffFunc = AIFunctionFactory.CreateDeclaration($"{HandoffWorkflowBuilder.FunctionPrefix}{index}", handoff.Reason, s_handoffSchema); + + functionNames.Add(handoffFunc.Name); + functionToAgentId[handoffFunc.Name] = handoff.Target.Id; + + result.ChatOptions.Tools.Add(handoffFunc); } } - AgentResponse agentResponse = updates.ToAgentResponse(); + return result; + } - if (options.EmitAgentResponseEvents) + private AIContentExternalHandler? _userInputHandler; + private AIContentExternalHandler? _functionCallHandler; + + protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) + { + return this.ConfigureUserInputHandling(base.ConfigureProtocol(protocolBuilder)) + .SendsMessage(); + } + + private ProtocolBuilder ConfigureUserInputHandling(ProtocolBuilder protocolBuilder) + { + this._userInputHandler = new AIContentExternalHandler( + ref protocolBuilder, + portId: $"{this.Id}_UserInput", + intercepted: false, + handler: this.HandleUserInputResponseAsync); + + this._functionCallHandler = new AIContentExternalHandler( + ref protocolBuilder, + portId: $"{this.Id}_FunctionCall", + intercepted: false, // TODO: Use this instead of manual function handling for handoff? + handler: this.HandleFunctionResultAsync); + + return protocolBuilder; + } + + private ValueTask HandleUserInputResponseAsync( + ToolApprovalResponseContent response, + IWorkflowContext context, + CancellationToken cancellationToken) + { + if (!this._userInputHandler!.MarkRequestAsHandled(response.RequestId)) { - await context.YieldOutputAsync(agentResponse, cancellationToken).ConfigureAwait(false); + throw new InvalidOperationException($"No pending ToolApprovalRequest found with id '{response.RequestId}'."); } - allMessages.AddRange(agentResponse.Messages); + // Merge the external response with any already-buffered regular messages so mixed-content + // resumes can be processed in one invocation. + return this.InvokeWithStateAsync((state, ctx, ct) => + { + state.TurnMessages.Add(new ChatMessage(ChatRole.User, [response]) + { + CreatedAt = DateTimeOffset.UtcNow, + MessageId = Guid.NewGuid().ToString("N"), + }); + + return this.ContinueTurnAsync(state, ctx, ct); + }, context, skipCache: false, cancellationToken); + } + + private ValueTask HandleFunctionResultAsync( + FunctionResultContent result, + IWorkflowContext context, + CancellationToken cancellationToken) + { + if (!this._functionCallHandler!.MarkRequestAsHandled(result.CallId)) + { + throw new InvalidOperationException($"No pending FunctionCall found with id '{result.CallId}'."); + } + + // Merge the external response with any already-buffered regular messages so mixed-content + // resumes can be processed in one invocation. + return this.InvokeWithStateAsync((state, ctx, ct) => + { + state.TurnMessages.Add( + new ChatMessage(ChatRole.Tool, [result]) + { + AuthorName = this._agent.Name ?? this._agent.Id, + CreatedAt = DateTimeOffset.UtcNow, + MessageId = Guid.NewGuid().ToString("N"), + }); + + return this.ContinueTurnAsync(state, ctx, ct); + }, context, skipCache: false, cancellationToken); + } + + private async ValueTask ContinueTurnAsync(HandoffAgentHostState state, IWorkflowContext context, CancellationToken cancellationToken) + { + List? roleChanges = state.FilteredIncomingMessages.ChangeAssistantToUserForOtherParticipants(this._agent.Name ?? this._agent.Id); + + bool emitUpdateEvents = state.CurrentTurnState!.ShouldEmitStreamingEvents(this._options.EmitAgentResponseUpdateEvents); + AgentInvocationResult result = await this.InvokeAgentAsync([.. state.FilteredIncomingMessages, .. state.TurnMessages], context, emitUpdateEvents, cancellationToken) + .ConfigureAwait(false); + + if (this.HasOutstandingRequests && result.IsHandoffRequested) + { + throw new InvalidOperationException("Cannot request a handoff while holding pending requests."); + } roleChanges.ResetUserToAssistantForChangedRoles(); - string currentAgentId = requestedHandoff is not null && this._handoffFunctionToAgentId.TryGetValue(requestedHandoff, out string? targetAgentId) - ? targetAgentId - : this._agent.Id; - - return new(message.TurnToken, requestedHandoff, allMessages, currentAgentId); - - async Task AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellationToken) + // We send on the HandoffState even if handoff is not requested because we might be terminating the processing, but this only + // happens if we have no outstanding requests. + if (!this.HasOutstandingRequests) { - updates.Add(update); - if (message.TurnToken.ShouldEmitStreamingEvents(options.EmitAgentResponseUpdateEvents)) + HandoffState outgoingState = state.PrepareHandoff(result, this._agent.Id); + + await context.SendMessageAsync(outgoingState, cancellationToken).ConfigureAwait(false); + + // reset the state for the next handoff (return-to-current is modeled as a new handoff turn, as opposed to "HITL", which + // can be a bit confusing.) + return null; + } + + state.TurnMessages.AddRange(result.Response.Messages); + return state; + } + + public override ValueTask HandleAsync(HandoffState message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + return this.InvokeWithStateAsync(InvokeContinueTurnAsync, context, skipCache: false, cancellationToken); + + ValueTask InvokeContinueTurnAsync(HandoffAgentHostState state, IWorkflowContext context, CancellationToken cancellationToken) + { + // Check that we are not getting this message while in the middle of a turn + if (state.CurrentTurnState != null) { - await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false); + throw new InvalidOperationException("Cannot have multiple simultaneous conversations in Handoff Orchestration."); } + + // If a handoff was invoked by a previous agent, filter out the handoff function + // call and tool result messages before sending to the underlying agent. These + // are internal workflow mechanics that confuse the target model into ignoring the + // original user question. + HandoffMessagesFilter handoffMessagesFilter = new(this._options.ToolCallFilteringBehavior); + IEnumerable messagesForAgent = message.RequestedHandoffTargetAgentId is not null + ? handoffMessagesFilter.FilterMessages(message.Messages) + : message.Messages; + + // This works because the runtime guarantees that a given executor instance will process messages serially, + // though there is no global cross-executor ordering guarantee (and in turn, no canonical message delivery order) + state = new(message, messagesForAgent.ToList(), []); + + return this.ContinueTurnAsync(state, context, cancellationToken); } } - public ValueTask ResetAsync() => default; + private const string UserInputRequestStateKey = nameof(_userInputHandler); + private const string FunctionCallRequestStateKey = nameof(_functionCallHandler); + + protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + Task userInputRequestsTask = this._userInputHandler?.OnCheckpointingAsync(UserInputRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask; + Task functionCallRequestsTask = this._functionCallHandler?.OnCheckpointingAsync(FunctionCallRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask; + + Task baseTask = base.OnCheckpointingAsync(context, cancellationToken).AsTask(); + await Task.WhenAll(userInputRequestsTask, functionCallRequestsTask, baseTask).ConfigureAwait(false); + } + + protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) + { + Task userInputRestoreTask = this._userInputHandler?.OnCheckpointRestoredAsync(UserInputRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask; + Task functionCallRestoreTask = this._functionCallHandler?.OnCheckpointRestoredAsync(FunctionCallRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask; + + await Task.WhenAll(userInputRestoreTask, functionCallRestoreTask).ConfigureAwait(false); + await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false); + } + private bool HasOutstandingRequests => (this._userInputHandler?.HasPendingRequests == true) + || (this._functionCallHandler?.HasPendingRequests == true); + + private async ValueTask InvokeAgentAsync(IEnumerable messages, IWorkflowContext context, bool emitUpdateEvents, CancellationToken cancellationToken = default) + { + AgentResponse response; + + AIAgentUnservicedRequestsCollector collector = new(this._userInputHandler, this._functionCallHandler); + + IAsyncEnumerable agentStream = this._agent.RunStreamingAsync( + messages, + options: this._agentOptions, + cancellationToken: cancellationToken); + + string? requestedHandoff = null; + List updates = []; + List candidateRequests = []; + await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false)) + { + await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false); + + collector.ProcessAgentResponseUpdate(update, CollectHandoffRequestsFilter); + + bool CollectHandoffRequestsFilter(FunctionCallContent candidateHandoffRequest) + { + bool isHandoffRequest = this._handoffFunctionNames.Contains(candidateHandoffRequest.Name); + if (isHandoffRequest) + { + candidateRequests.Add(candidateHandoffRequest); + } + + return !isHandoffRequest; + } + } + + if (candidateRequests.Count > 1) + { + string message = $"Duplicate handoff requests in single turn ([{string.Join(", ", candidateRequests.Select(request => request.Name))}]). Using last ({candidateRequests.Last().Name})"; + await context.AddEventAsync(new WorkflowWarningEvent(message), cancellationToken).ConfigureAwait(false); + } + + if (candidateRequests.Count > 0) + { + FunctionCallContent handoffRequest = candidateRequests[candidateRequests.Count - 1]; + requestedHandoff = handoffRequest.Name; + + await AddUpdateAsync( + new AgentResponseUpdate + { + AgentId = this._agent.Id, + AuthorName = this._agent.Name ?? this._agent.Id, + Contents = [new FunctionResultContent(handoffRequest.CallId, "Transferred.")], + CreatedAt = DateTimeOffset.UtcNow, + MessageId = Guid.NewGuid().ToString("N"), + Role = ChatRole.Tool, + }, + cancellationToken + ) + .ConfigureAwait(false); + } + + response = updates.ToAgentResponse(); + + if (this._options.EmitAgentResponseEvents) + { + await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false); + } + + await collector.SubmitAsync(context, cancellationToken).ConfigureAwait(false); + + return new(response, LookupHandoffTarget(requestedHandoff)); + + ValueTask AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellationToken) + { + updates.Add(update); + + return emitUpdateEvents ? context.YieldOutputAsync(update, cancellationToken) : default; + } + + string? LookupHandoffTarget(string? requestedHandoff) + => requestedHandoff != null + ? this._handoffFunctionToAgentId.TryGetValue(requestedHandoff, out string? targetId) ? targetId : null + : null; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffEndExecutor.cs similarity index 86% rename from dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs rename to dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffEndExecutor.cs index 4a43c00a72..0ba8fc3501 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffEndExecutor.cs @@ -8,7 +8,7 @@ using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.Specialized; /// Executor used at the end of a handoff workflow to raise a final completed event. -internal sealed class HandoffsEndExecutor(bool returnToPrevious) : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor +internal sealed class HandoffEndExecutor(bool returnToPrevious) : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor { public const string ExecutorId = "HandoffEnd"; @@ -21,9 +21,9 @@ internal sealed class HandoffsEndExecutor(bool returnToPrevious) : Executor(Exec { if (returnToPrevious) { - await context.QueueStateUpdateAsync(HandoffConstants.CurrentAgentTrackerKey, - handoff.CurrentAgentId, - HandoffConstants.CurrentAgentTrackerScope, + await context.QueueStateUpdateAsync(HandoffConstants.PreviousAgentTrackerKey, + handoff.PreviousAgentId, + HandoffConstants.PreviousAgentTrackerScope, cancellationToken) .ConfigureAwait(false); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffStartExecutor.cs similarity index 71% rename from dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs rename to dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffStartExecutor.cs index 87c3b4566b..063f73bb6f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffStartExecutor.cs @@ -9,12 +9,12 @@ namespace Microsoft.Agents.AI.Workflows.Specialized; internal static class HandoffConstants { - internal const string CurrentAgentTrackerKey = "LastAgentId"; - internal const string CurrentAgentTrackerScope = "HandoffOrchestration"; + internal const string PreviousAgentTrackerKey = "LastAgentId"; + internal const string PreviousAgentTrackerScope = "HandoffOrchestration"; } /// Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token. -internal sealed class HandoffsStartExecutor(bool returnToPrevious) : ChatProtocolExecutor(ExecutorId, DefaultOptions, declareCrossRunShareable: true), IResettableExecutor +internal sealed class HandoffStartExecutor(bool returnToPrevious) : ChatProtocolExecutor(ExecutorId, DefaultOptions, declareCrossRunShareable: true), IResettableExecutor { internal const string ExecutorId = "HandoffStart"; @@ -32,15 +32,15 @@ internal sealed class HandoffsStartExecutor(bool returnToPrevious) : ChatProtoco if (returnToPrevious) { return context.InvokeWithStateAsync( - async (string? currentAgentId, IWorkflowContext context, CancellationToken cancellationToken) => + async (string? previousAgentId, IWorkflowContext context, CancellationToken cancellationToken) => { - HandoffState handoffState = new(new(emitEvents), null, messages, currentAgentId); + HandoffState handoffState = new(new(emitEvents), null, messages, previousAgentId); await context.SendMessageAsync(handoffState, cancellationToken).ConfigureAwait(false); - return currentAgentId; + return previousAgentId; }, - HandoffConstants.CurrentAgentTrackerKey, - HandoffConstants.CurrentAgentTrackerScope, + HandoffConstants.PreviousAgentTrackerKey, + HandoffConstants.PreviousAgentTrackerScope, cancellationToken); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs index 56e2fef9df..644bc7df0e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs @@ -7,6 +7,6 @@ namespace Microsoft.Agents.AI.Workflows.Specialized; internal sealed record class HandoffState( TurnToken TurnToken, - string? InvokedHandoff, + string? RequestedHandoffTargetAgentId, List Messages, - string? CurrentAgentId = null); + string? PreviousAgentId = null); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs index 3ed23cc019..d1d239506f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/StatefulExecutor.cs @@ -113,6 +113,12 @@ public abstract class StatefulExecutor : Executor { if (!skipCache && !context.ConcurrentRunsEnabled) { + if (this._stateCache is null) + { + this._stateCache = await context.ReadOrInitStateAsync(this.StateKey, this._initialStateFactory, this.Options.ScopeName, cancellationToken) + .ConfigureAwait(false); + } + TState newState = await invocation(this._stateCache ?? this._initialStateFactory(), context, cancellationToken).ConfigureAwait(false) @@ -168,9 +174,12 @@ public abstract class StatefulExecutor(string id, /// protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) { - protocolBuilder.RouteBuilder.AddHandler(this.HandleAsync); + Func handlerDelegate = this.HandleAsync; - return protocolBuilder.SendsMessageTypes(sentMessageTypes ?? []) + return protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(handlerDelegate)) + .AddMethodAttributeTypes(handlerDelegate.Method) + .AddClassAttributeTypes(this.GetType()) + .SendsMessageTypes(sentMessageTypes ?? []) .YieldsOutputTypes(outputTypes ?? []); } @@ -203,19 +212,12 @@ public abstract class StatefulExecutor(string id, /// protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) { - protocolBuilder.RouteBuilder.AddHandler(this.HandleAsync); - - if (this.Options.AutoSendMessageHandlerResultObject) - { - protocolBuilder.SendsMessage(); - } - - if (this.Options.AutoYieldOutputHandlerResultObject) - { - protocolBuilder.YieldsOutput(); - } - - return protocolBuilder.SendsMessageTypes(sentMessageTypes ?? []).YieldsOutputTypes(outputTypes ?? []); + Func> handlerDelegate = this.HandleAsync; + return protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(handlerDelegate)) + .AddMethodAttributeTypes(handlerDelegate.Method) + .AddClassAttributeTypes(this.GetType()) + .SendsMessageTypes(sentMessageTypes ?? []) + .YieldsOutputTypes(outputTypes ?? []); } /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs index 7f06145a8e..c857811b08 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs @@ -9,6 +9,7 @@ using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using FluentAssertions; using Microsoft.Agents.AI.Workflows.InProc; using Microsoft.Extensions.AI; @@ -147,7 +148,7 @@ public class AgentWorkflowBuilderTests for (int iter = 0; iter < 3; iter++) { const string UserInput = "abc"; - (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); + (string updateText, List? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); Assert.NotNull(result); Assert.Equal(numAgents + 1, result.Count); @@ -225,7 +226,7 @@ public class AgentWorkflowBuilderTests barrier.Value = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); remaining.Value = 2; - (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, List? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.NotEmpty(updateText); Assert.NotNull(result); @@ -258,7 +259,7 @@ public class AgentWorkflowBuilderTests }), description: "nop")) .Build(); - (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, List? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.Equal("Hello from agent1", updateText); Assert.NotNull(result); @@ -296,7 +297,7 @@ public class AgentWorkflowBuilderTests .WithHandoff(initialAgent, nextAgent) .Build(); - (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, List? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.Equal("Hello from agent2", updateText); Assert.NotNull(result); @@ -406,7 +407,7 @@ public class AgentWorkflowBuilderTests .WithHandoff(secondAgent, thirdAgent) .Build(); - (string updateText, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, _, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.Contains("Hello from agent3", updateText); @@ -604,7 +605,7 @@ public class AgentWorkflowBuilderTests .WithHandoff(secondAgent, thirdAgent) .Build(); - (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + (string updateText, List? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); Assert.Equal("Hello from agent3", updateText); Assert.NotNull(result); @@ -634,6 +635,232 @@ public class AgentWorkflowBuilderTests Assert.Contains("thirdAgent", result[5].AuthorName); } + [Fact] + public async Task Handoffs_TwoTransfers_SecondAgentUserApproval_ResponseServedByThirdAgentAsync() + { + var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + ChatMessage message = Assert.Single(messages); + Assert.Equal("abc", Assert.IsType(Assert.Single(message.Contents)).Text); + + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + // Only a handoff function call. + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); + }), name: "initialAgent"); + + bool secondAgentInvoked = false; + + const string SomeOtherFunctionCallId = "call2first"; + + AIFunction someOtherFunction = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(SomeOtherFunction)); + + var secondAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + if (!secondAgentInvoked) + { + secondAgentInvoked = true; + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent(SomeOtherFunctionCallId, someOtherFunction.Name)])); + } + + // Second agent should receive the conversation so far (including previous assistant + tool messages eventually). + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", transferFuncName)])); + }), name: "secondAgent", description: "The second agent", tools: [someOtherFunction]); + + var thirdAgent = new ChatClientAgent(new MockChatClient((messages, options) => + new(new ChatMessage(ChatRole.Assistant, "Hello from agent3"))), + name: "thirdAgent", + description: "The third / final agent"); + + var workflow = + AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) + .WithHandoff(initialAgent, secondAgent) + .WithHandoff(secondAgent, thirdAgent) + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep; + + (string updateText, List? result, CheckpointInfo? lastCheckpoint, List requests) = + await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "abc")], Environment, checkpointManager); + + Assert.Null(result); + Assert.NotNull(requests); + + requests.Should().HaveCount(1); + ExternalRequest request = requests[0].Request; + + ToolApprovalRequestContent approvalRequest = + request.Data.As().Should().NotBeNull() + .And.Subject.As(); + + approvalRequest.ToolCall.CallId.Should().Be(SomeOtherFunctionCallId); + + ExternalResponse response = request.CreateResponse(approvalRequest.CreateResponse(false, "Denied")); + + (updateText, result, _, requests) = + await RunWorkflowCheckpointedAsync(workflow, response, Environment, checkpointManager, lastCheckpoint); + + Assert.Equal("Hello from agent3", updateText); + Assert.NotNull(result); + + // User + (assistant empty + tool) for each of first two agents + final assistant with text. + Assert.Equal(10, result.Count); + + Assert.Equal(ChatRole.User, result[0].Role); + Assert.Equal("abc", result[0].Text); + + Assert.Equal(ChatRole.Assistant, result[1].Role); + Assert.Equal("", result[1].Text); + Assert.Contains("initialAgent", result[1].AuthorName); + + Assert.Equal(ChatRole.Tool, result[2].Role); + Assert.Contains("initialAgent", result[2].AuthorName); + + // Non-handoff tool invocation (and user denial) + Assert.Equal(ChatRole.Assistant, result[3].Role); + Assert.Equal("", result[3].Text); + Assert.Contains("secondAgent", result[3].AuthorName); + + Assert.Equal(ChatRole.User, result[4].Role); + Assert.Equal("", result[4].Text); + + // Rejected tool call + Assert.Equal(ChatRole.Assistant, result[5].Role); + Assert.Equal("", result[5].Text); + Assert.Contains("secondAgent", result[5].AuthorName); + + Assert.Equal(ChatRole.Tool, result[6].Role); + Assert.Contains("secondAgent", result[6].AuthorName); + + // Handoff invocation + Assert.Equal(ChatRole.Assistant, result[7].Role); + Assert.Equal("", result[7].Text); + Assert.Contains("secondAgent", result[7].AuthorName); + + Assert.Equal(ChatRole.Tool, result[8].Role); + Assert.Contains("secondAgent", result[8].AuthorName); + + Assert.Equal(ChatRole.Assistant, result[9].Role); + Assert.Equal("Hello from agent3", result[9].Text); + Assert.Contains("thirdAgent", result[9].AuthorName); + + static bool SomeOtherFunction() => true; + } + + [Fact] + public async Task Handoffs_TwoTransfers_SecondAgentToolCall_ResponseServedByThirdAgentAsync() + { + var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + ChatMessage message = Assert.Single(messages); + Assert.Equal("abc", Assert.IsType(Assert.Single(message.Contents)).Text); + + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + // Only a handoff function call. + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); + }), name: "initialAgent"); + + bool secondAgentInvoked = false; + + const string SomeOtherFunctionName = "SomeOtherFunction"; + const string SomeOtherFunctionCallId = "call2first"; + + JsonElement otherFunctionSchema = AIFunctionFactory.Create(() => true).JsonSchema; + AIFunctionDeclaration someOtherFunction = AIFunctionFactory.CreateDeclaration(SomeOtherFunctionName, "Another function", otherFunctionSchema); + + var secondAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + if (!secondAgentInvoked) + { + secondAgentInvoked = true; + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent(SomeOtherFunctionCallId, SomeOtherFunctionName)])); + } + + // Second agent should receive the conversation so far (including previous assistant + tool messages eventually). + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", transferFuncName)])); + }), name: "secondAgent", description: "The second agent", tools: [someOtherFunction]); + + var thirdAgent = new ChatClientAgent(new MockChatClient((messages, options) => + new(new ChatMessage(ChatRole.Assistant, "Hello from agent3"))), + name: "thirdAgent", + description: "The third / final agent"); + + var workflow = + AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) + .WithHandoff(initialAgent, secondAgent) + .WithHandoff(secondAgent, thirdAgent) + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep; + + (string updateText, List? result, CheckpointInfo? lastCheckpoint, List requests) = + await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "abc")], Environment, checkpointManager); + + Assert.Null(result); + Assert.NotNull(requests); + + requests.Should().HaveCount(1); + ExternalRequest request = requests[0].Request; + + FunctionCallContent functionCall = request.Data.As().Should().NotBeNull() + .And.Subject.As(); + + functionCall.CallId.Should().Be(SomeOtherFunctionCallId); + functionCall.Name.Should().Be(SomeOtherFunctionName); + + ExternalResponse response = request.CreateResponse(new FunctionResultContent(functionCall.CallId, true)); + + (updateText, result, _, requests) = + await RunWorkflowCheckpointedAsync(workflow, response, Environment, checkpointManager, lastCheckpoint); + + Assert.Equal("Hello from agent3", updateText); + Assert.NotNull(result); + + // User + (assistant empty + tool) for each of first two agents + final assistant with text. + Assert.Equal(8, result.Count); + + Assert.Equal(ChatRole.User, result[0].Role); + Assert.Equal("abc", result[0].Text); + + Assert.Equal(ChatRole.Assistant, result[1].Role); + Assert.Equal("", result[1].Text); + Assert.Contains("initialAgent", result[1].AuthorName); + + Assert.Equal(ChatRole.Tool, result[2].Role); + Assert.Contains("initialAgent", result[2].AuthorName); + + // Non-handoff tool invocation + Assert.Equal(ChatRole.Assistant, result[3].Role); + Assert.Equal("", result[3].Text); + Assert.Contains("secondAgent", result[3].AuthorName); + + Assert.Equal(ChatRole.Tool, result[4].Role); + Assert.Contains("secondAgent", result[4].AuthorName); + + // Handoff invocation + Assert.Equal(ChatRole.Assistant, result[5].Role); + Assert.Equal("", result[5].Text); + Assert.Contains("secondAgent", result[5].AuthorName); + + Assert.Equal(ChatRole.Tool, result[6].Role); + Assert.Contains("secondAgent", result[6].AuthorName); + + Assert.Equal(ChatRole.Assistant, result[7].Role); + Assert.Equal("Hello from agent3", result[7].Text); + Assert.Contains("thirdAgent", result[7].AuthorName); + } + [Theory] [InlineData(1)] [InlineData(2)] @@ -651,7 +878,7 @@ public class AgentWorkflowBuilderTests for (int iter = 0; iter < 3; iter++) { const string UserInput = "abc"; - (string updateText, List? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); + (string updateText, List? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]); Assert.NotNull(result); Assert.Equal(maxIterations + 1, result.Count); @@ -832,7 +1059,7 @@ public class AgentWorkflowBuilderTests Assert.Equal(1, specialistCallCount); // specialist NOT called } - private sealed record WorkflowRunResult(string UpdateText, List? Result, CheckpointInfo? LastCheckpoint); + private sealed record WorkflowRunResult(string UpdateText, List? Result, CheckpointInfo? LastCheckpoint, List PendingRequests); private static Task RunWorkflowCheckpointedAsync( Workflow workflow, List input, ExecutionEnvironment executionEnvironment, CheckpointManager checkpointManager, CheckpointInfo? fromCheckpoint = null) @@ -843,6 +1070,15 @@ public class AgentWorkflowBuilderTests return RunWorkflowCheckpointedAsync(workflow, input, environment, fromCheckpoint); } + private static Task RunWorkflowCheckpointedAsync( + Workflow workflow, ExternalResponse response, ExecutionEnvironment executionEnvironment, CheckpointManager checkpointManager, CheckpointInfo? fromCheckpoint = null) + { + InProcessExecutionEnvironment environment = executionEnvironment.ToWorkflowExecutionEnvironment() + .WithCheckpointing(checkpointManager); + + return RunWorkflowCheckpointedAsync(workflow, response, environment, fromCheckpoint); + } + private static async Task RunWorkflowCheckpointedAsync( Workflow workflow, List input, InProcessExecutionEnvironment environment, CheckpointInfo? fromCheckpoint = null) { @@ -853,15 +1089,39 @@ public class AgentWorkflowBuilderTests await run.TrySendMessageAsync(input); await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); + return await ProcessWorkflowRunAsync(run); + } + + private static async Task RunWorkflowCheckpointedAsync( + Workflow workflow, ExternalResponse response, InProcessExecutionEnvironment environment, CheckpointInfo? fromCheckpoint = null) + { + await using StreamingRun run = + fromCheckpoint != null ? await environment.ResumeStreamingAsync(workflow, fromCheckpoint) + : await environment.OpenStreamingAsync(workflow); + + await run.SendResponseAsync(response); + + return await ProcessWorkflowRunAsync(run); + } + + private static async Task ProcessWorkflowRunAsync(StreamingRun run) + { StringBuilder sb = new(); WorkflowOutputEvent? output = null; CheckpointInfo? lastCheckpoint = null; - await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false)) + + List pendingRequests = []; + + await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false).ConfigureAwait(false)) { switch (evt) { - case AgentResponseUpdateEvent executorComplete: - sb.Append(executorComplete.Data); + case AgentResponseUpdateEvent responseUpdate: + sb.Append(responseUpdate.Data); + break; + + case RequestInfoEvent requestInfo: + pendingRequests.Add(requestInfo); break; case WorkflowOutputEvent e: @@ -878,7 +1138,7 @@ public class AgentWorkflowBuilderTests } } - return new(sb.ToString(), output?.As>(), lastCheckpoint); + return new(sb.ToString(), output?.As>(), lastCheckpoint, pendingRequests); } private static Task RunWorkflowAsync( diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs index 8bdbe23c5f..1a5b2ea4d1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs @@ -29,7 +29,7 @@ public class HandoffAgentExecutorTests : AIAgentHostingExecutorTestsBase emitAgentResponseUpdateEvents: executorSetting, HandoffToolCallFilteringBehavior.None); - HandoffAgentExecutor executor = new(agent, options); + HandoffAgentExecutor executor = new(agent, [], options); testContext.ConfigureExecutor(executor); // Act @@ -57,7 +57,7 @@ public class HandoffAgentExecutorTests : AIAgentHostingExecutorTestsBase emitAgentResponseUpdateEvents: false, HandoffToolCallFilteringBehavior.None); - HandoffAgentExecutor executor = new(agent, options); + HandoffAgentExecutor executor = new(agent, [], options); testContext.ConfigureExecutor(executor); // Act From b1fb63eb81785e1f5d72d0738df868fb250077a9 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Mon, 13 Apr 2026 19:03:51 +0100 Subject: [PATCH 20/22] .NET: Update AGUI service to support session storage (#5193) * Update AGUI service to support session storage * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Address PR comments --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../AGUIClientServer/AGUIServer/Program.cs | 17 +- .../AGUIEndpointRouteBuilderExtensions.cs | 80 ++++++- ...t.Agents.AI.Hosting.AGUI.AspNetCore.csproj | 1 + ...ng.AGUI.AspNetCore.IntegrationTests.csproj | 1 + .../SessionPersistenceTests.cs | 226 ++++++++++++++++++ ...AGUIEndpointRouteBuilderExtensionsTests.cs | 191 +++++++++++++++ 6 files changed, 509 insertions(+), 7 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SessionPersistenceTests.cs diff --git a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs index d2c17a5541..a12ca1c5ad 100644 --- a/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs +++ b/dotnet/samples/05-end-to-end/AGUIClientServer/AGUIServer/Program.cs @@ -4,6 +4,7 @@ using System.ComponentModel; using AGUIServer; using Azure.AI.OpenAI; using Azure.Identity; +using Microsoft.Agents.AI.Hosting; using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; using Microsoft.Extensions.AI; using OpenAI.Chat; @@ -13,11 +14,11 @@ builder.Services.AddHttpClient().AddLogging(); builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Add(AGUIServerSerializerContext.Default)); builder.Services.AddAGUI(); -WebApplication app = builder.Build(); - string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set."); +const string AgentName = "AGUIAssistant"; + // Create the AI agent with tools // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid @@ -27,7 +28,7 @@ var agent = new AzureOpenAIClient( new DefaultAzureCredential()) .GetChatClient(deploymentName) .AsAIAgent( - name: "AGUIAssistant", + name: AgentName, tools: [ AIFunctionFactory.Create( () => DateTimeOffset.UtcNow, @@ -48,7 +49,15 @@ var agent = new AzureOpenAIClient( AGUIServerSerializerContext.Default.Options) ]); +// Register the agent with the host and configure it to use an in-memory session store +// so that conversation state is maintained across requests. In production, you may want to use a persistent session store. +builder + .AddAIAgent(AgentName, (_, _) => agent) + .WithInMemorySessionStore(); + +WebApplication app = builder.Build(); + // Map the AG-UI agent endpoint -app.MapAGUI("/", agent); +app.MapAGUI(AgentName, "/"); await app.RunAsync(); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs index e20d1ab448..948ecdca42 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/AGUIEndpointRouteBuilderExtensions.cs @@ -1,9 +1,12 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Runtime.CompilerServices; using System.Threading; +using System.Threading.Tasks; using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -21,6 +24,42 @@ namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore; /// public static class AGUIEndpointRouteBuilderExtensions { + /// + /// Maps an AG-UI agent endpoint using an agent registered in dependency injection via . + /// + /// The endpoint route builder. + /// The hosted agent builder that identifies the agent registration. + /// The URL pattern for the endpoint. + /// An for the mapped endpoint. + public static IEndpointConventionBuilder MapAGUI( + this IEndpointRouteBuilder endpoints, + IHostedAgentBuilder agentBuilder, + [StringSyntax("route")] string pattern) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentNullException.ThrowIfNull(agentBuilder); + return endpoints.MapAGUI(agentBuilder.Name, pattern); + } + + /// + /// Maps an AG-UI agent endpoint using a named agent registered in dependency injection. + /// + /// The endpoint route builder. + /// The name of the keyed agent registration to resolve from dependency injection. + /// The URL pattern for the endpoint. + /// An for the mapped endpoint. + public static IEndpointConventionBuilder MapAGUI( + this IEndpointRouteBuilder endpoints, + string agentName, + [StringSyntax("route")] string pattern) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentNullException.ThrowIfNull(agentName); + + var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); + return endpoints.MapAGUI(pattern, agent); + } + /// /// Maps an AG-UI agent endpoint. /// @@ -28,11 +67,24 @@ public static class AGUIEndpointRouteBuilderExtensions /// The URL pattern for the endpoint. /// The agent instance. /// An for the mapped endpoint. + /// + /// + /// If an is registered in dependency injection keyed by the agent's name, + /// it will be used to persist conversation sessions across requests using the AG-UI thread ID as the + /// conversation identifier. If no session store is registered, sessions are ephemeral (not persisted). + /// + /// public static IEndpointConventionBuilder MapAGUI( this IEndpointRouteBuilder endpoints, [StringSyntax("route")] string pattern, AIAgent aiAgent) { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentNullException.ThrowIfNull(aiAgent); + + var agentSessionStore = endpoints.ServiceProvider.GetKeyedService(aiAgent.Name); + var hostAgent = new AIHostAgent(aiAgent, agentSessionStore ?? new NoopAgentSessionStore()); + return endpoints.MapPost(pattern, async ([FromBody] RunAgentInput? input, HttpContext context, CancellationToken cancellationToken) => { if (input is null) @@ -63,21 +115,43 @@ public static class AGUIEndpointRouteBuilderExtensions } }; + var threadId = string.IsNullOrWhiteSpace(input.ThreadId) ? Guid.NewGuid().ToString("N") : input.ThreadId; + var session = await hostAgent.GetOrCreateSessionAsync(threadId, cancellationToken).ConfigureAwait(false); + // Run the agent and convert to AG-UI events - var events = aiAgent.RunStreamingAsync( + var events = hostAgent.RunStreamingAsync( messages, + session: session, options: runOptions, cancellationToken: cancellationToken) .AsChatResponseUpdatesAsync() .FilterServerToolsFromMixedToolInvocationsAsync(clientTools, cancellationToken) .AsAGUIEventStreamAsync( - input.ThreadId, + threadId, input.RunId, jsonSerializerOptions, cancellationToken); + // Wrap the event stream to save the session after streaming completes + var eventsWithSessionSave = SaveSessionAfterStreamingAsync(events, hostAgent, threadId, session, cancellationToken); + var sseLogger = context.RequestServices.GetRequiredService>(); - return new AGUIServerSentEventsResult(events, sseLogger); + return new AGUIServerSentEventsResult(eventsWithSessionSave, sseLogger); }); } + + private static async IAsyncEnumerable SaveSessionAfterStreamingAsync( + IAsyncEnumerable events, + AIHostAgent hostAgent, + string threadId, + AgentSession session, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (BaseEvent evt in events.ConfigureAwait(false)) + { + yield return evt; + } + + await hostAgent.SaveSessionAsync(threadId, session, cancellationToken).ConfigureAwait(false); + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj index d6169ad805..1565977149 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj @@ -19,6 +19,7 @@ + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj index 490f816cd4..e0b072a44b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj @@ -21,6 +21,7 @@ + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SessionPersistenceTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SessionPersistenceTests.cs new file mode 100644 index 0000000000..785a3b2e00 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/SessionPersistenceTests.cs @@ -0,0 +1,226 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Agents.AI.AGUI; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests; + +public sealed class SessionPersistenceTests : IAsyncDisposable +{ + private WebApplication? _app; + private HttpClient? _client; + + [Fact] + public async Task MultiTurnWithSessionStore_PersistsSessionAcrossRequestsAsync() + { + // Arrange - use hosting DI pattern with InMemorySessionStore. + // FakeSessionAgent tracks turn count in session StateBag so we can verify + // that state survives the serialization round-trip through the session store. + await this.SetupTestServerWithSessionStoreAsync(); + var chatClient = new AGUIChatClient(this._client!, "", null); + AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []); + ChatClientAgentSession session = (ChatClientAgentSession)await agent.CreateSessionAsync(); + + // Act - First turn + ChatMessage firstUserMessage = new(ChatRole.User, "First message"); + List firstTurnUpdates = []; + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([firstUserMessage], session, new AgentRunOptions(), CancellationToken.None)) + { + firstTurnUpdates.Add(update); + } + + // Act - Second turn (same thread ID to test session persistence) + ChatMessage secondUserMessage = new(ChatRole.User, "Second message"); + List secondTurnUpdates = []; + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([secondUserMessage], session, new AgentRunOptions(), CancellationToken.None)) + { + secondTurnUpdates.Add(update); + } + + // Assert - Verify turn count proves session state was persisted. + // If session persistence were broken, both turns would return "Turn 1" + // because a fresh session (with turn count 0) would be created each time. + AgentResponse firstResponse = firstTurnUpdates.ToAgentResponse(); + firstResponse.Messages.Should().HaveCount(1); + firstResponse.Messages[0].Role.Should().Be(ChatRole.Assistant); + firstResponse.Messages[0].Text.Should().Contain("Turn 1:"); + + AgentResponse secondResponse = secondTurnUpdates.ToAgentResponse(); + secondResponse.Messages.Should().HaveCount(1); + secondResponse.Messages[0].Role.Should().Be(ChatRole.Assistant); + secondResponse.Messages[0].Text.Should().Contain("Turn 2:"); + } + + [Fact] + public async Task MapAGUI_WithAgentName_StreamsResponseCorrectlyAsync() + { + // Arrange - use the MapAGUI(agentName, pattern) overload via hosting DI + await this.SetupTestServerWithSessionStoreAsync(); + var chatClient = new AGUIChatClient(this._client!, "", null); + AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []); + ChatClientAgentSession session = (ChatClientAgentSession)await agent.CreateSessionAsync(); + ChatMessage userMessage = new(ChatRole.User, "hello"); + + List updates = []; + + // Act + await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([userMessage], session, new AgentRunOptions(), CancellationToken.None)) + { + updates.Add(update); + } + + // Assert + updates.Should().NotBeEmpty(); + updates.Should().AllSatisfy(u => u.Role.Should().Be(ChatRole.Assistant)); + + AgentResponse response = updates.ToAgentResponse(); + response.Messages.Should().HaveCount(1); + response.Messages[0].Role.Should().Be(ChatRole.Assistant); + response.Messages[0].Text.Should().Be("Turn 1: Hello from session agent!"); + } + + private async Task SetupTestServerWithSessionStoreAsync() + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + builder.Services.AddAGUI(); + + // Register agent using hosting DI pattern with InMemorySessionStore + builder.Services.AddAIAgent("session-test-agent", (_, name) => new FakeSessionAgent(name)) + .WithInMemorySessionStore(); + + this._app = builder.Build(); + + // Use the agentName overload of MapAGUI + this._app.MapAGUI("session-test-agent", "/agent"); + + await this._app.StartAsync(); + + TestServer testServer = this._app.Services.GetRequiredService() as TestServer + ?? throw new InvalidOperationException("TestServer not found"); + + this._client = testServer.CreateClient(); + this._client.BaseAddress = new Uri("http://localhost/agent"); + } + + public async ValueTask DisposeAsync() + { + this._client?.Dispose(); + if (this._app != null) + { + await this._app.DisposeAsync(); + } + } +} + +[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated via dependency injection")] +internal sealed class FakeSessionAgent : AIAgent +{ + private readonly string _name; + + public FakeSessionAgent(string name) + { + this._name = name; + } + + protected override string? IdCore => this._name; + + public override string? Name => this._name; + + public override string? Description => "A fake agent with session support for testing"; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => + new(new FakeSessionAgentSession()); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(serializedState.Deserialize(jsonSerializerOptions)!); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + { + if (session is not FakeSessionAgentSession fakeSession) + { + throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent."); + } + + return new(JsonSerializer.SerializeToElement(fakeSession, jsonSerializerOptions)); + } + + protected override async Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (AgentResponseUpdate update in this.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false)) + { + updates.Add(update); + } + + return updates.ToAgentResponse(); + } + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // Track turn count in session state to enable persistence verification. + // If the session store works correctly, the turn count increments across requests. + int turnCount = 1; + if (session != null) + { + var counter = session.StateBag.GetValue("turnCounter"); + turnCount = (counter?.Count ?? 0) + 1; + session.StateBag.SetValue("turnCounter", new TurnCounter { Count = turnCount }); + } + + string messageId = Guid.NewGuid().ToString("N"); + string prefix = $"Turn {turnCount}: "; + + foreach (string chunk in new[] { prefix, "Hello", " ", "from", " ", "session", " ", "agent", "!" }) + { + yield return new AgentResponseUpdate + { + MessageId = messageId, + Role = ChatRole.Assistant, + Contents = [new TextContent(chunk)] + }; + + await Task.Yield(); + } + } + + internal sealed class TurnCounter + { + public int Count { get; set; } + } + + private sealed class FakeSessionAgentSession : AgentSession + { + public FakeSessionAgentSession() + { + } + + [JsonConstructor] + public FakeSessionAgentSession(AgentSessionStateBag stateBag) : base(stateBag) + { + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs index 84a20e1938..248629b392 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.UnitTests/AGUIEndpointRouteBuilderExtensionsTests.cs @@ -14,6 +14,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -31,6 +32,7 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests // Arrange Mock endpointsMock = new(); Mock serviceProviderMock = new(); + serviceProviderMock.As(); endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); endpointsMock.Setup(e => e.DataSources).Returns([]); @@ -45,6 +47,155 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests Assert.NotNull(result); } + [Fact] + public void MapAGUI_WithAgentName_ResolvesKeyedAgentFromDI() + { + // Arrange + Mock endpointsMock = new(); + Mock serviceProviderMock = new(); + AIAgent agent = new NamedTestAgent(); + + serviceProviderMock.As() + .Setup(sp => sp.GetRequiredKeyedService(typeof(AIAgent), "test-agent")) + .Returns(agent); + + endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); + endpointsMock.Setup(e => e.DataSources).Returns([]); + + // Act + IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI("test-agent", "/api/agent"); + + // Assert + Assert.NotNull(result); + serviceProviderMock.As() + .Verify(sp => sp.GetRequiredKeyedService(typeof(AIAgent), "test-agent"), Times.Once); + } + + [Fact] + public void MapAGUI_WithHostedAgentBuilder_ResolvesAgentByBuilderName() + { + // Arrange + Mock endpointsMock = new(); + Mock serviceProviderMock = new(); + Mock agentBuilderMock = new(); + AIAgent agent = new NamedTestAgent(); + + agentBuilderMock.Setup(b => b.Name).Returns("test-agent"); + + serviceProviderMock.As() + .Setup(sp => sp.GetRequiredKeyedService(typeof(AIAgent), "test-agent")) + .Returns(agent); + + endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); + endpointsMock.Setup(e => e.DataSources).Returns([]); + + // Act + IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI(agentBuilderMock.Object, "/api/agent"); + + // Assert + Assert.NotNull(result); + serviceProviderMock.As() + .Verify(sp => sp.GetRequiredKeyedService(typeof(AIAgent), "test-agent"), Times.Once); + } + + [Fact] + public void MapAGUI_WithAgent_ResolvesSessionStoreFromDI() + { + // Arrange + Mock endpointsMock = new(); + Mock serviceProviderMock = new(); + Mock sessionStoreMock = new(); + AIAgent agent = new NamedTestAgent(); + + serviceProviderMock.As() + .Setup(sp => sp.GetKeyedService(typeof(AgentSessionStore), "test-agent")) + .Returns(sessionStoreMock.Object); + + endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); + endpointsMock.Setup(e => e.DataSources).Returns([]); + + // Act + IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI("/api/agent", agent); + + // Assert + Assert.NotNull(result); + serviceProviderMock.As() + .Verify(sp => sp.GetKeyedService(typeof(AgentSessionStore), "test-agent"), Times.Once); + } + + [Fact] + public void MapAGUI_WithoutSessionStore_FallsBackToNoopStore() + { + // Arrange + Mock endpointsMock = new(); + Mock serviceProviderMock = new(); + AIAgent agent = new TestAgent(); + + // No session store registered - IKeyedServiceProvider returns null by default + serviceProviderMock.As(); + + endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); + endpointsMock.Setup(e => e.DataSources).Returns([]); + + // Act - should not throw (falls back to NoopAgentSessionStore) + IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI("/api/agent", agent); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void MapAGUI_WithNullEndpoints_ThrowsArgumentNullException() + { + // Arrange + AIAgent agent = new TestAgent(); + + // Act & Assert + Assert.Throws(() => + AGUIEndpointRouteBuilderExtensions.MapAGUI(null!, "/api/agent", agent)); + } + + [Fact] + public void MapAGUI_WithNullAgent_ThrowsArgumentNullException() + { + // Arrange + Mock endpointsMock = new(); + Mock serviceProviderMock = new(); + serviceProviderMock.As(); + endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); + + // Act & Assert + Assert.Throws(() => + endpointsMock.Object.MapAGUI("/api/agent", (AIAgent)null!)); + } + + [Fact] + public void MapAGUI_WithNullAgentName_ThrowsArgumentNullException() + { + // Arrange + Mock endpointsMock = new(); + Mock serviceProviderMock = new(); + serviceProviderMock.As(); + endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); + + // Act & Assert + Assert.Throws(() => + endpointsMock.Object.MapAGUI((string)null!, "/api/agent")); + } + + [Fact] + public void MapAGUI_WithNullAgentBuilder_ThrowsArgumentNullException() + { + // Arrange + Mock endpointsMock = new(); + Mock serviceProviderMock = new(); + endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object); + + // Act & Assert + Assert.Throws(() => + endpointsMock.Object.MapAGUI((IHostedAgentBuilder)null!, "/api/agent")); + } + [Fact] public async Task MapAGUIAgent_WithNullOrInvalidInput_Returns400BadRequestAsync() { @@ -556,4 +707,44 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests yield return new AgentResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, "Test response")); } } + + private sealed class NamedTestAgent : AIAgent + { + protected override string? IdCore => "test-agent"; + + public override string? Name => "test-agent"; + + public override string? Description => "Named test agent"; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => + new(new TestAgentSession()); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(serializedState.Deserialize(jsonSerializerOptions)!); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + { + if (session is not TestAgentSession testSession) + { + throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(TestAgentSession)}' can be serialized by this agent."); + } + + return new(JsonSerializer.SerializeToElement(testSession, jsonSerializerOptions)); + } + + protected override Task RunCoreAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + yield return new AgentResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, "Test response")); + } + } } From 952e685e176d9499aff484224e7133885f16d4cf Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:28:06 +0100 Subject: [PATCH 21/22] Python: Fix python-feature-lifecycle skill YAML frontmatter (#5226) * Fix python-feature-lifecycle skill YAML frontmatter Remove copyright comment that preceded the YAML frontmatter delimiter, which prevented the skill from loading. The --- block must be the very first line of SKILL.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: update broken eslint-react plugin links in devui README The upstream eslint-react repo moved plugins from packages/plugins/ to the top-level plugins/ directory, causing 404 errors detected by linkspector CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/.github/skills/python-feature-lifecycle/SKILL.md | 2 -- python/packages/devui/frontend/README.md | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/python/.github/skills/python-feature-lifecycle/SKILL.md b/python/.github/skills/python-feature-lifecycle/SKILL.md index d9b654a9da..80e8af17c2 100644 --- a/python/.github/skills/python-feature-lifecycle/SKILL.md +++ b/python/.github/skills/python-feature-lifecycle/SKILL.md @@ -1,5 +1,3 @@ -# Copyright (c) Microsoft. All rights reserved. - --- name: python-feature-lifecycle description: > diff --git a/python/packages/devui/frontend/README.md b/python/packages/devui/frontend/README.md index ae7bec5b39..8c6c4a0009 100644 --- a/python/packages/devui/frontend/README.md +++ b/python/packages/devui/frontend/README.md @@ -51,7 +51,7 @@ export default tseslint.config([ ]) ``` -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/plugins/eslint-plugin-react-dom) for React-specific lint rules: ```js // eslint.config.js From 913397492fd9a4d42c46d7d874c409549686827d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:52:34 +0900 Subject: [PATCH 22/22] Bump pygments from 2.19.2 to 2.20.0 in /python (#4978) Bumps [pygments](https://github.com/pygments/pygments) from 2.19.2 to 2.20.0. - [Release notes](https://github.com/pygments/pygments/releases) - [Changelog](https://github.com/pygments/pygments/blob/master/CHANGES) - [Commits](https://github.com/pygments/pygments/compare/2.19.2...2.20.0) --- updated-dependencies: - dependency-name: pygments dependency-version: 2.20.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>