Python: Introducing support for declarative yaml spec (#2002)

* first work on declarative

* initial version of the declarative support

* fix tests and mypy

* fix parameters of functiontool

* slight logic improvement

* remove path until merge

* updates from comments

* create dispatcher and spec type, json_schema method

* fix mypy, skipping model

* updated lock

* fixed declarative tests and renamed some other test files

* refined loader

* updated lock

* fix mypy

* added readme to samples folder

* fixes from review

* undid test file rename
This commit is contained in:
Eduard van Valkenburg
2025-11-19 17:33:02 +01:00
committed by GitHub
Unverified
parent d2d0f46e15
commit 92df9e14bf
35 changed files with 3924 additions and 6 deletions
-1
View File
@@ -1 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
@@ -1 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
@@ -589,7 +589,7 @@ class ChatAgent(BaseAgent):
chat_message_store_factory: Callable[[], ChatMessageStoreProtocol] | None = None,
context_providers: ContextProvider | list[ContextProvider] | AggregateContextProvider | None = None,
middleware: Middleware | list[Middleware] | None = None,
# chat option params
# chat options
allow_multiple_tool_calls: bool | None = None,
conversation_id: str | None = None,
frequency_penalty: float | None = None,
@@ -214,6 +214,7 @@ def _merge_chat_options(
*,
base_chat_options: ChatOptions | Any | None,
model_id: str | None = None,
allow_multiple_tool_calls: bool | None = None,
frequency_penalty: float | None = None,
logit_bias: dict[str | int, float] | None = None,
max_tokens: int | None = None,
@@ -239,6 +240,7 @@ def _merge_chat_options(
Keyword Args:
base_chat_options: Optional base ChatOptions to merge with direct parameters.
model_id: The model_id to use for the agent.
allow_multiple_tool_calls: Whether to allow multiple tool calls in a single response.
frequency_penalty: The frequency penalty to use.
logit_bias: The logit bias to use.
max_tokens: The maximum number of tokens to generate.
@@ -270,6 +272,7 @@ def _merge_chat_options(
return base_chat_options & ChatOptions(
model_id=model_id,
allow_multiple_tool_calls=allow_multiple_tool_calls,
frequency_penalty=frequency_penalty,
logit_bias=logit_bias,
max_tokens=max_tokens,
@@ -485,6 +488,7 @@ class BaseChatClient(SerializationMixin, ABC):
self,
messages: str | ChatMessage | list[str] | list[ChatMessage],
*,
allow_multiple_tool_calls: bool | None = None,
frequency_penalty: float | None = None,
logit_bias: dict[str | int, float] | None = None,
max_tokens: int | None = None,
@@ -517,6 +521,7 @@ class BaseChatClient(SerializationMixin, ABC):
messages: The message or messages to send to the model.
Keyword Args:
allow_multiple_tool_calls: Whether to allow multiple tool calls in a single response.
frequency_penalty: The frequency penalty to use.
logit_bias: The logit bias to use.
max_tokens: The maximum number of tokens to generate.
@@ -545,6 +550,7 @@ class BaseChatClient(SerializationMixin, ABC):
chat_options = _merge_chat_options(
base_chat_options=kwargs.pop("chat_options", None),
model_id=model_id,
allow_multiple_tool_calls=allow_multiple_tool_calls,
frequency_penalty=frequency_penalty,
logit_bias=logit_bias,
max_tokens=max_tokens,
@@ -580,6 +586,7 @@ class BaseChatClient(SerializationMixin, ABC):
self,
messages: str | ChatMessage | list[str] | list[ChatMessage],
*,
allow_multiple_tool_calls: bool | None = None,
frequency_penalty: float | None = None,
logit_bias: dict[str | int, float] | None = None,
max_tokens: int | None = None,
@@ -612,6 +619,7 @@ class BaseChatClient(SerializationMixin, ABC):
messages: The message or messages to send to the model.
Keyword Args:
allow_multiple_tool_calls: Whether to allow multiple tool calls in a single response.
frequency_penalty: The frequency penalty to use.
logit_bias: The logit bias to use.
max_tokens: The maximum number of tokens to generate.
@@ -640,6 +648,7 @@ class BaseChatClient(SerializationMixin, ABC):
chat_options = _merge_chat_options(
base_chat_options=kwargs.pop("chat_options", None),
model_id=model_id,
allow_multiple_tool_calls=allow_multiple_tool_calls,
frequency_penalty=frequency_penalty,
logit_bias=logit_bias,
max_tokens=max_tokens,
@@ -0,0 +1,23 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib
from typing import Any
IMPORT_PATH = "agent_framework_declarative"
PACKAGE_NAME = "agent-framework-declarative"
_IMPORTS = ["__version__", "AgentFactory", "DeclarativeLoaderError", "ProviderLookupError", "ProviderTypeMapping"]
def __getattr__(name: str) -> Any:
if name in _IMPORTS:
try:
return getattr(importlib.import_module(IMPORT_PATH), name)
except ModuleNotFoundError as exc:
raise ModuleNotFoundError(
f"The '{PACKAGE_NAME}' package is not installed, please do `pip install {PACKAGE_NAME}`"
) from exc
raise AttributeError(f"Module {IMPORT_PATH} has no attribute {name}.")
def __dir__() -> list[str]:
return _IMPORTS
@@ -0,0 +1,17 @@
# Copyright (c) Microsoft. All rights reserved.
from agent_framework_declarative import (
AgentFactory,
DeclarativeLoaderError,
ProviderLookupError,
ProviderTypeMapping,
__version__,
)
__all__ = [
"AgentFactory",
"DeclarativeLoaderError",
"ProviderLookupError",
"ProviderTypeMapping",
"__version__",
]
+1
View File
@@ -48,6 +48,7 @@ all = [
"agent-framework-azurefunctions",
"agent-framework-chatkit",
"agent-framework-copilotstudio",
"agent-framework-declarative",
"agent-framework-devui",
"agent-framework-lab",
"agent-framework-mem0",
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
+11
View File
@@ -0,0 +1,11 @@
# Get Started with Microsoft Agent Framework Declarative
Please install this package via pip:
```bash
pip install agent-framework-declarative --pre
```
## Declarative features
The declarative packages provides support for building agents based on a declarative yaml specification.
@@ -0,0 +1,12 @@
# Copyright (c) Microsoft. All rights reserved.
from importlib import metadata
from ._loader import AgentFactory, DeclarativeLoaderError, ProviderLookupError, ProviderTypeMapping
try:
__version__ = metadata.version(__name__)
except metadata.PackageNotFoundError:
__version__ = "0.0.0" # Fallback for development mode
__all__ = ["AgentFactory", "DeclarativeLoaderError", "ProviderLookupError", "ProviderTypeMapping", "__version__"]
@@ -0,0 +1,422 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Callable, Mapping
from pathlib import Path
from typing import Any, Literal, TypedDict
import yaml
from agent_framework import (
AIFunction,
ChatAgent,
ChatClientProtocol,
HostedCodeInterpreterTool,
HostedFileContent,
HostedFileSearchTool,
HostedMCPSpecificApproval,
HostedMCPTool,
HostedVectorStoreContent,
HostedWebSearchTool,
ToolProtocol,
)
from agent_framework._tools import _create_model_from_json_schema # type: ignore
from agent_framework.exceptions import AgentFrameworkException
from dotenv import load_dotenv
from ._models import (
AnonymousConnection,
ApiKeyConnection,
CodeInterpreterTool,
FileSearchTool,
FunctionTool,
McpServerToolSpecifyApprovalMode,
McpTool,
Model,
ModelOptions,
PromptAgent,
ReferenceConnection,
RemoteConnection,
Tool,
WebSearchTool,
agent_schema_dispatch,
)
class ProviderTypeMapping(TypedDict, total=True):
package: str
name: str
model_id_field: str
PROVIDER_TYPE_OBJECT_MAPPING: dict[str, ProviderTypeMapping] = {
"AzureOpenAI.Chat": {
"package": "agent_framework.azure",
"name": "AzureOpenAIChatClient",
"model_id_field": "deployment_name",
},
"AzureOpenAI.Assistants": {
"package": "agent_framework.azure",
"name": "AzureOpenAIAssistantsClient",
"model_id_field": "deployment_name",
},
"AzureOpenAI.Responses": {
"package": "agent_framework.azure",
"name": "AzureOpenAIResponsesClient",
"model_id_field": "deployment_name",
},
"OpenAI.Chat": {
"package": "agent_framework.openai",
"name": "OpenAIChatClient",
"model_id_field": "model_id",
},
"OpenAI.Assistants": {
"package": "agent_framework.openai",
"name": "OpenAIAssistantsClient",
"model_id_field": "model_id",
},
"OpenAI.Responses": {
"package": "agent_framework.openai",
"name": "OpenAIResponsesClient",
"model_id_field": "model_id",
},
"AzureAIAgentClient": {
"package": "agent_framework.azure",
"name": "AzureAIAgentClient",
"model_id_field": "model_deployment_name",
},
"AzureAIClient": {
"package": "agent_framework.azure",
"name": "AzureAIClient",
"model_id_field": "model_deployment_name",
},
"Anthropic.Chat": {
"package": "agent_framework.anthropic",
"name": "AnthropicChatClient",
"model_id_field": "model_id",
},
}
class DeclarativeLoaderError(AgentFrameworkException):
"""Exception raised for errors in the declarative loader."""
pass
class ProviderLookupError(DeclarativeLoaderError):
"""Exception raised for errors in provider type lookup."""
pass
class AgentFactory:
def __init__(
self,
*,
chat_client: ChatClientProtocol | None = None,
bindings: Mapping[str, Any] | None = None,
connections: Mapping[str, Any] | None = None,
client_kwargs: Mapping[str, Any] | None = None,
additional_mappings: Mapping[str, ProviderTypeMapping] | None = None,
default_provider: str = "AzureAIClient",
env_file: str | None = None,
) -> None:
"""Create the agent factory, with bindings.
Args:
chat_client: An optional ChatClientProtocol instance to use as a dependency,
this will be passed to the ChatAgent that get's created.
If you need to create multiple agents with different chat clients,
do not pass this and instead provide the chat client in the YAML definition.
bindings: An optional dictionary of bindings to use when creating agents.
connections: An optional dictionary of connections to resolve ReferenceConnections.
client_kwargs: An optional dictionary of keyword arguments to pass to chat client constructor.
additional_mappings: An optional dictionary to extend the provider type to object mapping.
Should have the structure:
..code-block:: python
additional_mappings = {
"Provider.ApiType": {
"package": "package.name",
"name": "ClassName",
"model_id_field": "field_name_in_constructor",
},
...
}
Here, "Provider.ApiType" is the lookup key used when both provider and apiType are specified in the
model, "Provider" is also allowed.
Package refers to which model needs to be imported, Name is the class name of the ChatClientProtocol
implementation, and model_id_field is the name of the field in the constructor
that accepts the model.id value.
default_provider: The default provider used when model.provider is not specified,
default is "AzureAIClient".
env_file: An optional path to a .env file to load environment variables from.
"""
self.chat_client = chat_client
self.bindings = bindings
self.connections = connections
self.client_kwargs = client_kwargs or {}
self.additional_mappings = additional_mappings or {}
self.default_provider: str = default_provider
load_dotenv(dotenv_path=env_file)
def create_agent_from_yaml_path(self, yaml_path: str | Path) -> ChatAgent:
"""Create a ChatAgent from a YAML file path.
This method does the following things:
1. Loads the YAML file into a AgentSchema object using open and agent_schema_dispatch.
2. Validates that the loaded object is a PromptAgent.
3. Creates the appropriate ChatClient based on the model provider and apiType.
4. Parses the tools, options, and response format from the PromptAgent.
5. Creates and returns a ChatAgent instance with the configured properties.
Args:
yaml_path: Path to the YAML file representation of a AgentSchema object
Returns:
The ``ChatAgent`` instance created from the YAML file.
Raises:
DeclarativeLoaderError: If the YAML does not represent a PromptAgent.
ProviderLookupError: If the provider type is unknown or unsupported.
ValueError: If a ReferenceConnection cannot be resolved.
ModuleNotFoundError: If the required module for the provider type cannot be imported.
AttributeError: If the required class for the provider type cannot be found in the module.
"""
if not isinstance(yaml_path, Path):
yaml_path = Path(yaml_path)
if not yaml_path.exists():
raise DeclarativeLoaderError(f"YAML file not found at path: {yaml_path}")
with open(yaml_path) as f:
yaml_str = f.read()
return self.create_agent_from_yaml(yaml_str)
def create_agent_from_yaml(self, yaml_str: str) -> ChatAgent:
"""Create a ChatAgent from a YAML string.
This method does the following things:
1. Loads the YAML string into a AgentSchema object using agent_schema_dispatch.
2. Validates that the loaded object is a PromptAgent.
3. Creates the appropriate ChatClient based on the model provider and apiType.
4. Parses the tools, options, and response format from the PromptAgent.
5. Creates and returns a ChatAgent instance with the configured properties.
Args:
yaml_str: YAML string representation of a AgentSchema object
Returns:
The ``ChatAgent`` instance created from the YAML string.
Raises:
DeclarativeLoaderError: If the YAML does not represent a PromptAgent.
ProviderLookupError: If the provider type is unknown or unsupported.
ValueError: If a ReferenceConnection cannot be resolved.
ModuleNotFoundError: If the required module for the provider type cannot be imported.
AttributeError: If the required class for the provider type cannot be found in the module.
"""
prompt_agent = agent_schema_dispatch(yaml.safe_load(yaml_str))
if not isinstance(prompt_agent, PromptAgent):
raise DeclarativeLoaderError("Only yaml definitions for a PromptAgent are supported for agent creation.")
# Step 1: Create the ChatClient
client = self._get_client(prompt_agent)
# Step 2: Get the chat options
chat_options = self._parse_chat_options(prompt_agent.model)
if tools := self._parse_tools(prompt_agent.tools):
chat_options["tools"] = tools
if output_schema := prompt_agent.outputSchema:
chat_options["response_format"] = _create_model_from_json_schema("agent", output_schema.to_json_schema())
# Step 3: Create the agent instance
return ChatAgent(
chat_client=client,
name=prompt_agent.name,
description=prompt_agent.description,
instructions=prompt_agent.instructions,
**chat_options,
)
def _get_client(self, prompt_agent: PromptAgent) -> ChatClientProtocol:
"""Create the ChatClientProtocol instance based on the PromptAgent model."""
if not prompt_agent.model:
# if no model is defined, use the supplied chat_client
if self.chat_client:
return self.chat_client
raise DeclarativeLoaderError(
"ChatClient must be provided to create agent from PromptAgent, "
"alternatively define a model in the PromptAgent."
)
setup_dict: dict[str, Any] = {}
setup_dict.update(self.client_kwargs)
# parse connections
if prompt_agent.model.connection:
match prompt_agent.model.connection:
case ApiKeyConnection():
setup_dict["api_key"] = prompt_agent.model.connection.apiKey
if prompt_agent.model.connection.endpoint:
setup_dict["endpoint"] = prompt_agent.model.connection.endpoint
case RemoteConnection() | AnonymousConnection():
setup_dict["endpoint"] = prompt_agent.model.connection.endpoint
case ReferenceConnection():
if not self.connections:
raise ValueError("Connections must be provided to resolve ReferenceConnection")
# find the referenced connection
if prompt_agent.model.connection.name and (
value := self.connections.get(prompt_agent.model.connection.name)
):
setup_dict[prompt_agent.model.connection.name] = value
else:
raise ValueError(
f"ReferenceConnection with name {prompt_agent.model.connection.name} not found in provided "
"connections."
)
# Any client we create, needs a model.id
if not prompt_agent.model.id:
# if prompt_agent.model is defined, but no id, use the supplied chat_client
if self.chat_client:
return self.chat_client
# or raise, since we cannot create a client without model id
raise DeclarativeLoaderError(
"ChatClient must be provided to create agent from PromptAgent, or define model.id in the PromptAgent."
)
# if provider is defined, use that, if possible with apiType, fallback to default_provider
mapping = self._retrieve_provider_configuration(prompt_agent.model)
module_name = mapping["package"]
class_name = mapping["name"]
module = __import__(module_name, fromlist=[class_name])
agent_class = getattr(module, class_name)
setup_dict[mapping["model_id_field"]] = prompt_agent.model.id
return agent_class(**setup_dict) # type: ignore[no-any-return]
def _parse_chat_options(self, model: Model | None) -> dict[str, Any]:
"""Parse ModelOptions into chat options dictionary."""
chat_options: dict[str, Any] = {}
if not model or not model.options or not isinstance(model.options, ModelOptions):
return chat_options
options = model.options
if options.frequencyPenalty is not None:
chat_options["frequency_penalty"] = options.frequencyPenalty
if options.presencePenalty is not None:
chat_options["presence_penalty"] = options.presencePenalty
if options.maxOutputTokens is not None:
chat_options["max_tokens"] = options.maxOutputTokens
if options.temperature is not None:
chat_options["temperature"] = options.temperature
if options.topP is not None:
chat_options["top_p"] = options.topP
if options.seed is not None:
chat_options["seed"] = options.seed
if options.stopSequences:
chat_options["stop"] = options.stopSequences
if options.allowMultipleToolCalls is not None:
chat_options["allow_multiple_tool_calls"] = options.allowMultipleToolCalls
if (chat_tool_mode := options.additionalProperties.pop("chatToolMode", None)) is not None:
chat_options["tool_choice"] = chat_tool_mode
if options.additionalProperties:
chat_options["additional_chat_options"] = options.additionalProperties
return chat_options
def _parse_tools(self, tools: list[Tool] | None) -> list[ToolProtocol] | None:
"""Parse tool resources into ToolProtocol instances."""
if not tools:
return None
return [self._parse_tool(tool_resource) for tool_resource in tools]
def _parse_tool(self, tool_resource: Tool) -> ToolProtocol:
"""Parse a single tool resource into a ToolProtocol instance."""
match tool_resource:
case FunctionTool():
func: Callable[..., Any] | None = None
if self.bindings and tool_resource.bindings:
for binding in tool_resource.bindings:
if binding.name and (func := self.bindings.get(binding.name)):
break
return AIFunction( # type: ignore
name=tool_resource.name, # type: ignore
description=tool_resource.description, # type: ignore
input_model=tool_resource.parameters.to_json_schema() if tool_resource.parameters else None,
func=func,
)
case WebSearchTool():
return HostedWebSearchTool(
description=tool_resource.description, additional_properties=tool_resource.options
)
case FileSearchTool():
add_props: dict[str, Any] = {}
if tool_resource.ranker is not None:
add_props["ranker"] = tool_resource.ranker
if tool_resource.scoreThreshold is not None:
add_props["score_threshold"] = tool_resource.scoreThreshold
if tool_resource.filters:
add_props["filters"] = tool_resource.filters
return HostedFileSearchTool(
inputs=[HostedVectorStoreContent(id) for id in tool_resource.vectorStoreIds or []],
description=tool_resource.description,
max_results=tool_resource.maximumResultCount,
additional_properties=add_props,
)
case CodeInterpreterTool():
return HostedCodeInterpreterTool(
inputs=[HostedFileContent(file_id=file) for file in tool_resource.fileIds or []],
description=tool_resource.description,
)
case McpTool():
approval_mode: HostedMCPSpecificApproval | Literal["always_require", "never_require"] | None = None
if tool_resource.approvalMode is not None:
if tool_resource.approvalMode.kind == "always":
approval_mode = "always_require"
elif tool_resource.approvalMode.kind == "never":
approval_mode = "never_require"
elif isinstance(tool_resource.approvalMode, McpServerToolSpecifyApprovalMode):
approval_mode = {}
if tool_resource.approvalMode.alwaysRequireApprovalTools:
approval_mode["always_require_approval"] = (
tool_resource.approvalMode.alwaysRequireApprovalTools
)
if tool_resource.approvalMode.neverRequireApprovalTools:
approval_mode["never_require_approval"] = (
tool_resource.approvalMode.neverRequireApprovalTools
)
if not approval_mode:
approval_mode = None
return HostedMCPTool(
name=tool_resource.name, # type: ignore
description=tool_resource.description,
url=tool_resource.url, # type: ignore
allowed_tools=tool_resource.allowedTools,
approval_mode=approval_mode,
)
case _:
raise ValueError(f"Unsupported tool kind: {tool_resource.kind}")
def _retrieve_provider_configuration(self, model: Model) -> ProviderTypeMapping:
"""Retrieve the provider configuration based on the model's provider and apiType.
If only provider is specified, it will be used.
If both provider and apiType are specified, both will be used.
If neither is specified, the default_provider will be used.
Args:
model: The Model instance containing provider and apiType information.
Returns:
A dictionary containing the package, name, and model_id_field for the provider.
Raises:
ProviderLookupError: If the provider type is not supported or can't be found.
"""
class_lookup = (
f"{model.provider}.{model.apiType}"
if model.apiType
else f"{model.provider}"
if model.provider
else self.default_provider
)
if class_lookup in self.additional_mappings:
return self.additional_mappings[class_lookup]
if class_lookup not in PROVIDER_TYPE_OBJECT_MAPPING:
raise ProviderLookupError(f"Unsupported provider type: {class_lookup}")
return PROVIDER_TYPE_OBJECT_MAPPING[class_lookup]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,97 @@
[project]
name = "agent-framework-declarative"
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.0b251028"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core",
"powerfx>=0.0.31; python_version < '3.14'",
"pyyaml>=6.0,<7.0",
]
[dependency-groups]
dev = [
"types-PyYaml"
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = [
"ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*"
]
timeout = 120
[tool.ruff]
extend = "../../pyproject.toml"
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extends = "../../pyproject.toml"
exclude = ['tests']
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
exclude = [
'_models.py$',
]
[tool.bandit]
targets = ["agent_framework_declarative"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_declarative"
test = "pytest --cov=agent_framework_declarative --cov-report=term-missing:skip-covered tests"
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
@@ -0,0 +1,456 @@
# Copyright (c) Microsoft. All rights reserved.
import sys
from pathlib import Path
from typing import Any
import pytest
import yaml
from agent_framework_declarative._models import (
AgentDefinition,
AgentManifest,
AnonymousConnection,
ApiKeyConnection,
ArrayProperty,
CodeInterpreterTool,
Connection,
CustomTool,
FileSearchTool,
FunctionTool,
McpServerApprovalMode,
McpServerToolAlwaysRequireApprovalMode,
McpServerToolNeverRequireApprovalMode,
McpServerToolSpecifyApprovalMode,
McpTool,
ModelResource,
ObjectProperty,
OpenApiTool,
PromptAgent,
Property,
PropertySchema,
ReferenceConnection,
RemoteConnection,
Resource,
ToolResource,
WebSearchTool,
agent_schema_dispatch,
)
pytestmark = pytest.mark.skipif(sys.version_info >= (3, 14), reason="Skipping on Python 3.14+")
@pytest.mark.parametrize(
"yaml_content,expected_type,expected_attributes",
[
# Agent Manifest (no kind field)
(
"""
name: my-manifest
description: A test manifest
""",
AgentManifest,
{"name": "my-manifest", "description": "A test manifest"},
),
# PromptAgent
(
"""
kind: Prompt
name: assistant
description: A helpful assistant
model:
id: gpt-4
""",
PromptAgent,
{"name": "assistant", "description": "A helpful assistant"},
),
# AgentDefinition
(
"""
kind: Agent
name: base-agent
description: A base agent
""",
AgentDefinition,
{"name": "base-agent", "description": "A base agent"},
),
# ModelResource
(
"""
kind: Model
name: my-model
id: gpt-4
""",
ModelResource,
{"name": "my-model", "id": "gpt-4"},
),
# ToolResource
(
"""
kind: Tool
name: my-tool
id: search-tool
""",
ToolResource,
{"name": "my-tool", "id": "search-tool"},
),
# Resource (base)
(
"""
kind: Resource
name: generic-resource
""",
Resource,
{"name": "generic-resource"},
),
# FunctionTool
(
"""
kind: function
name: get_weather
description: Get the weather
""",
FunctionTool,
{"name": "get_weather", "description": "Get the weather"},
),
# CustomTool
(
"""
kind: custom
name: custom_tool
description: A custom tool
""",
CustomTool,
{"name": "custom_tool", "description": "A custom tool"},
),
# WebSearchTool
(
"""
kind: web_search
name: search
description: Search the web
""",
WebSearchTool,
{"name": "search", "description": "Search the web"},
),
# FileSearchTool
(
"""
kind: file_search
name: file_search
description: Search files
""",
FileSearchTool,
{"name": "file_search", "description": "Search files"},
),
# McpTool
(
"""
kind: mcp
name: mcp_tool
description: An MCP tool
serverName: my-server
""",
McpTool,
{"name": "mcp_tool", "serverName": "my-server"},
),
# OpenApiTool
(
"""
kind: openapi
name: api_tool
description: An OpenAPI tool
specification: https://api.example.com/openapi.json
""",
OpenApiTool,
{"name": "api_tool", "specification": "https://api.example.com/openapi.json"},
),
# CodeInterpreterTool
(
"""
kind: code_interpreter
name: code_tool
description: A code interpreter tool
""",
CodeInterpreterTool,
{"name": "code_tool", "description": "A code interpreter tool"},
),
# ReferenceConnection
(
"""
kind: reference
name: my-connection
target: target-connection
""",
ReferenceConnection,
{"name": "my-connection", "target": "target-connection"},
),
# RemoteConnection
(
"""
kind: remote
endpoint: https://api.example.com
""",
RemoteConnection,
{"endpoint": "https://api.example.com"},
),
# ApiKeyConnection
(
"""
kind: key
apiKey: secret-key
endpoint: https://api.example.com
""",
ApiKeyConnection,
{"apiKey": "secret-key", "endpoint": "https://api.example.com"},
),
# AnonymousConnection
(
"""
kind: anonymous
endpoint: https://api.example.com
""",
AnonymousConnection,
{"endpoint": "https://api.example.com"},
),
# Connection (base)
(
"""
kind: connection
authenticationMode: oauth
""",
Connection,
{"authenticationMode": "oauth"},
),
# ArrayProperty
(
"""
kind: array
name: items
description: An array of items
""",
ArrayProperty,
{"name": "items", "description": "An array of items"},
),
# ObjectProperty
(
"""
kind: object
name: config
description: Configuration object
""",
ObjectProperty,
{"name": "config", "description": "Configuration object"},
),
# Property (base)
(
"""
kind: property
name: field
description: A property field
""",
Property,
{"name": "field", "description": "A property field"},
),
# McpServerToolAlwaysRequireApprovalMode
(
"""
kind: always
""",
McpServerToolAlwaysRequireApprovalMode,
{},
),
# McpServerToolNeverRequireApprovalMode
(
"""
kind: never
""",
McpServerToolNeverRequireApprovalMode,
{},
),
# McpServerToolSpecifyApprovalMode
(
"""
kind: specify
alwaysRequireApprovalTools: []
neverRequireApprovalTools: []
""",
McpServerToolSpecifyApprovalMode,
{},
),
# McpServerApprovalMode (base)
(
"""
kind: approval_mode
""",
McpServerApprovalMode,
{},
),
],
)
def test_agent_schema_dispatch_all_types(yaml_content: str, expected_type: type, expected_attributes: dict[str, Any]):
"""Test that agent_schema_dispatch correctly loads all MAML object types."""
result = agent_schema_dispatch(yaml.safe_load(yaml_content))
# Check the type is correct
assert isinstance(result, expected_type), f"Expected {expected_type.__name__}, got {type(result).__name__}"
# Check expected attributes
for attr_name, attr_value in expected_attributes.items():
assert hasattr(result, attr_name), f"Result missing attribute '{attr_name}'"
assert getattr(result, attr_name) == attr_value, (
f"Attribute '{attr_name}' has value {getattr(result, attr_name)}, expected {attr_value}"
)
def test_agent_schema_dispatch_unknown_kind():
"""Test that agent_schema_dispatch returns None for unknown kind."""
yaml_content = """
kind: unknown_type
name: test
"""
result = agent_schema_dispatch(yaml.safe_load(yaml_content))
assert result is None
def test_agent_schema_dispatch_complex_agent_manifest():
"""Test loading a complex agent manifest with nested objects."""
yaml_content = """
name: complex-manifest
description: A complete manifest
template:
kind: Prompt
name: assistant
description: A helpful assistant
model:
id: gpt-4
provider: openai
tools:
- kind: web_search
name: search
description: Search the web
- kind: function
name: calculator
description: Calculate math
resources:
- kind: model
name: model1
id: gpt-4
- kind: tool
name: tool1
id: search
"""
result = agent_schema_dispatch(yaml.safe_load(yaml_content))
assert isinstance(result, AgentManifest)
assert result.name == "complex-manifest"
assert result.description == "A complete manifest"
assert isinstance(result.template, PromptAgent)
assert result.template.name == "assistant"
assert len(result.resources) == 2
assert isinstance(result.resources[0], ModelResource)
assert isinstance(result.resources[1], ToolResource)
def test_agent_schema_dispatch_prompt_agent_with_tools():
"""Test loading a prompt agent with multiple tools."""
yaml_content = """
kind: Prompt
name: multi-tool-agent
description: Agent with multiple tools
model:
id: gpt-4
tools:
- kind: web_search
name: search
description: Search the web
- kind: function
name: get_weather
description: Get weather information
- kind: code_interpreter
name: code
description: Execute code
"""
result = agent_schema_dispatch(yaml.safe_load(yaml_content))
assert isinstance(result, PromptAgent)
assert result.name == "multi-tool-agent"
assert len(result.tools) == 3
# Tools are polymorphically created based on their kind
assert result.tools[0].kind == "web_search"
assert result.tools[1].kind == "function"
assert result.tools[2].kind == "code_interpreter"
def test_agent_schema_dispatch_model_resource():
"""Test loading a model resource."""
yaml_content = """
kind: Model
name: my-model
id: gpt-4
"""
result = agent_schema_dispatch(yaml.safe_load(yaml_content))
assert isinstance(result, ModelResource)
assert result.id == "gpt-4"
def test_agent_schema_dispatch_property_schema_with_nested_properties():
"""Test loading a property schema with nested properties."""
yaml_content = """
kind: property_schema
strict: true
properties:
- kind: property
name: name
description: User name
- kind: object
name: address
description: User address
properties:
- kind: property
name: street
description: Street address
- kind: property
name: city
description: City name
- kind: array
name: tags
description: User tags
"""
result = agent_schema_dispatch(yaml.safe_load(yaml_content))
assert isinstance(result, PropertySchema)
assert result.strict is True
assert len(result.properties) == 3
# Properties are polymorphically created based on their kind
assert result.properties[0].kind == "property"
assert result.properties[1].kind == "object"
assert result.properties[2].kind == "array"
def _get_agent_sample_yaml_files() -> list[tuple[Path, Path]]:
"""Helper function to collect all YAML files from agent-samples directory."""
current_file = Path(__file__)
repo_root = current_file.parent.parent.parent.parent # tests -> declarative -> packages -> python
agent_samples_dir = repo_root.parent / "agent-samples"
if not agent_samples_dir.exists():
return []
yaml_files = list(agent_samples_dir.rglob("*.yaml")) + list(agent_samples_dir.rglob("*.yml"))
return [(yaml_file, agent_samples_dir) for yaml_file in yaml_files]
@pytest.mark.parametrize(
"yaml_file,agent_samples_dir",
_get_agent_sample_yaml_files(),
ids=lambda x: x[0].name if isinstance(x, tuple) else str(x),
)
def test_agent_schema_dispatch_agent_samples(yaml_file: Path, agent_samples_dir: Path):
"""Test that agent_schema_dispatch successfully loads a YAML file from agent-samples directory."""
with open(yaml_file) as f:
content = f.read()
result = agent_schema_dispatch(yaml.safe_load(content))
# Result can be None for unknown kinds, but should not raise exceptions
assert result is not None, f"agent_schema_dispatch returned None for {yaml_file.relative_to(agent_samples_dir)}"
File diff suppressed because it is too large Load Diff