From 95f8592b9fad9190a8c0b4b7e0d6fccbda2b7649 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 4 Nov 2025 19:57:17 +0100 Subject: [PATCH 01/17] first work on declarative --- agent-samples/README.md | 3 + agent-samples/azure/AzureOpenAI.yaml | 25 + .../azure/AzureOpenAIAssistants.yaml | 25 + agent-samples/azure/AzureOpenAIResponses.yaml | 25 + agent-samples/chatclient/Assistant.yaml | 18 + agent-samples/chatclient/GetWeather.yaml | 26 + .../foundry/MicrosoftLearnAgent.yaml | 20 + agent-samples/foundry/PersistentAgent.yaml | 22 + agent-samples/openai/OpenAI.yaml | 28 + agent-samples/openai/OpenAIAssistants.yaml | 30 + agent-samples/openai/OpenAIResponses.yaml | 28 + python/packages/core/pyproject.toml | 1 + python/packages/declarative/LICENSE | 21 + python/packages/declarative/README.md | 11 + .../agent_framework_declarative/__init__.py | 5 + .../agent_framework_declarative/_loader.py | 150 ++++ .../agent_framework_declarative/_models.py | 810 ++++++++++++++++++ python/packages/declarative/pyproject.toml | 88 ++ .../packages/declarative/tests/test_loader.py | 422 +++++++++ .../packages/declarative/tests/test_models.py | 778 +++++++++++++++++ python/pyproject.toml | 7 +- .../declarative/simple_agent.py | 19 + python/uv.lock | 18 + 23 files changed, 2577 insertions(+), 3 deletions(-) create mode 100644 agent-samples/README.md create mode 100644 agent-samples/azure/AzureOpenAI.yaml create mode 100644 agent-samples/azure/AzureOpenAIAssistants.yaml create mode 100644 agent-samples/azure/AzureOpenAIResponses.yaml create mode 100644 agent-samples/chatclient/Assistant.yaml create mode 100644 agent-samples/chatclient/GetWeather.yaml create mode 100644 agent-samples/foundry/MicrosoftLearnAgent.yaml create mode 100644 agent-samples/foundry/PersistentAgent.yaml create mode 100644 agent-samples/openai/OpenAI.yaml create mode 100644 agent-samples/openai/OpenAIAssistants.yaml create mode 100644 agent-samples/openai/OpenAIResponses.yaml create mode 100644 python/packages/declarative/LICENSE create mode 100644 python/packages/declarative/README.md create mode 100644 python/packages/declarative/agent_framework_declarative/__init__.py create mode 100644 python/packages/declarative/agent_framework_declarative/_loader.py create mode 100644 python/packages/declarative/agent_framework_declarative/_models.py create mode 100644 python/packages/declarative/pyproject.toml create mode 100644 python/packages/declarative/tests/test_loader.py create mode 100644 python/packages/declarative/tests/test_models.py create mode 100644 python/samples/getting_started/declarative/simple_agent.py diff --git a/agent-samples/README.md b/agent-samples/README.md new file mode 100644 index 0000000000..91e45605db --- /dev/null +++ b/agent-samples/README.md @@ -0,0 +1,3 @@ +# Declarative Agents + +This folder contains sample agent definitions than be ran using the [Declarative Agents](../dotnet/samples/GettingStarted/DeclarativeAgents) demo. diff --git a/agent-samples/azure/AzureOpenAI.yaml b/agent-samples/azure/AzureOpenAI.yaml new file mode 100644 index 0000000000..4fd574bc1f --- /dev/null +++ b/agent-samples/azure/AzureOpenAI.yaml @@ -0,0 +1,25 @@ +kind: Prompt +name: Assistant +description: Helpful assistant +instructions: You are a helpful assistant. You answer questions is the language specified by the user. You return your answers in a JSON format. You must include Chat as the type in your response. +model: + id: =Env.AZURE_OPENAI_DEPLOYMENT_NAME + provider: AzureOpenAI + apiType: Chat + options: + temperature: 0.9 + topP: 0.95 +outputSchema: + properties: + language: + type: string + required: true + description: The language of the answer. + answer: + type: string + required: true + description: The answer text. + type: + type: string + required: true + description: The type of the response. diff --git a/agent-samples/azure/AzureOpenAIAssistants.yaml b/agent-samples/azure/AzureOpenAIAssistants.yaml new file mode 100644 index 0000000000..e3882695c2 --- /dev/null +++ b/agent-samples/azure/AzureOpenAIAssistants.yaml @@ -0,0 +1,25 @@ +kind: Prompt +name: Assistant +description: Helpful assistant +instructions: You are a helpful assistant. You answer questions is the language specified by the user. You return your answers in a JSON format. You must include Assistants as the type in your response. +model: + id: =Env.AZURE_OPENAI_DEPLOYMENT_NAME + provider: AzureOpenAI + apiType: Assistants + options: + temperature: 0.9 + topP: 0.95 +outputSchema: + properties: + language: + type: string + required: true + description: The language of the answer. + answer: + type: string + required: true + description: The answer text. + type: + type: string + required: true + description: The type of the response. diff --git a/agent-samples/azure/AzureOpenAIResponses.yaml b/agent-samples/azure/AzureOpenAIResponses.yaml new file mode 100644 index 0000000000..fc70ac82eb --- /dev/null +++ b/agent-samples/azure/AzureOpenAIResponses.yaml @@ -0,0 +1,25 @@ +kind: Prompt +name: Assistant +description: Helpful assistant +instructions: You are a helpful assistant. You answer questions is the language specified by the user. You return your answers in a JSON format. You must include Responses as the type in your response. +model: + id: =Env.AZURE_OPENAI_DEPLOYMENT_NAME + provider: AzureOpenAI + apiType: Responses + options: + temperature: 0.9 + topP: 0.95 +outputSchema: + properties: + language: + type: string + required: true + description: The language of the answer. + answer: + type: string + required: true + description: The answer text. + type: + type: string + required: true + description: The type of the response. diff --git a/agent-samples/chatclient/Assistant.yaml b/agent-samples/chatclient/Assistant.yaml new file mode 100644 index 0000000000..81599d4e6f --- /dev/null +++ b/agent-samples/chatclient/Assistant.yaml @@ -0,0 +1,18 @@ +kind: Prompt +name: Assistant +description: Helpful assistant +instructions: You are a helpful assistant. You answer questions is the language specified by the user. You return your answers in a JSON format. +model: + options: + temperature: 0.9 + topP: 0.95 +outputSchema: + properties: + language: + type: string + required: true + description: The language of the answer. + answer: + type: string + required: true + description: The answer text. diff --git a/agent-samples/chatclient/GetWeather.yaml b/agent-samples/chatclient/GetWeather.yaml new file mode 100644 index 0000000000..798d2e4245 --- /dev/null +++ b/agent-samples/chatclient/GetWeather.yaml @@ -0,0 +1,26 @@ +kind: Prompt +name: Assistant +description: Helpful assistant +instructions: You are a helpful assistant. You answer questions using the tools provided. +model: + options: + temperature: 0.9 + topP: 0.95 + allowMultipleToolCalls: true + chatToolMode: auto +tools: + - kind: function + name: GetWeather + description: Get the weather for a given location. + parameters: + - name: location + type: string + description: The city and state, e.g. San Francisco, CA + required: true + - name: unit + type: string + description: The unit of temperature. Possible values are 'celsius' and 'fahrenheit'. + required: false + enum: + - celsius + - fahrenheit diff --git a/agent-samples/foundry/MicrosoftLearnAgent.yaml b/agent-samples/foundry/MicrosoftLearnAgent.yaml new file mode 100644 index 0000000000..817aeadc4e --- /dev/null +++ b/agent-samples/foundry/MicrosoftLearnAgent.yaml @@ -0,0 +1,20 @@ +kind: Prompt +name: MicrosoftLearnAgent +description: Microsoft Learn Agent +instructions: You answer questions by searching the Microsoft Learn content only. +model: + id: =Env.AZURE_FOUNDRY_PROJECT_MODEL_ID + options: + temperature: 0.9 + topP: 0.95 + connection: + kind: ExternalReference + endpoint: =Env.AZURE_FOUNDRY_PROJECT_ENDPOINT +tools: + - kind: mcp + name: microsoft_learn + description: Get information from Microsoft Learn. + url: https://learn.microsoft.com/api/mcp + requireApproval: requireSpecific + allowedTools: + - microsoft_docs_search \ No newline at end of file diff --git a/agent-samples/foundry/PersistentAgent.yaml b/agent-samples/foundry/PersistentAgent.yaml new file mode 100644 index 0000000000..774c51d864 --- /dev/null +++ b/agent-samples/foundry/PersistentAgent.yaml @@ -0,0 +1,22 @@ +kind: Prompt +name: Assistant +description: Helpful assistant +instructions: You are a helpful assistant. You answer questions is the language specified by the user. You return your answers in a JSON format. +model: + id: =Env.AZURE_FOUNDRY_PROJECT_MODEL_ID + options: + temperature: 0.9 + topP: 0.95 + connection: + kind: ExternalReference + endpoint: =Env.AZURE_FOUNDRY_PROJECT_ENDPOINT +outputSchema: + properties: + language: + type: string + required: true + description: The language of the answer. + answer: + type: string + required: true + description: The answer text. diff --git a/agent-samples/openai/OpenAI.yaml b/agent-samples/openai/OpenAI.yaml new file mode 100644 index 0000000000..4a9994c2ff --- /dev/null +++ b/agent-samples/openai/OpenAI.yaml @@ -0,0 +1,28 @@ +kind: Prompt +name: Assistant +description: Helpful assistant +instructions: You are a helpful assistant. You answer questions is the language specified by the user. You return your answers in a JSON format. You must include Chat as the type in your response. +model: + id: =Env.OPENAI_MODEL + provider: OpenAI + apiType: Chat + options: + temperature: 0.9 + topP: 0.95 + connection: + kind: ApiKey + key: =Env.OPENAI_APIKEY +outputSchema: + properties: + language: + type: string + required: true + description: The language of the answer. + answer: + type: string + required: true + description: The answer text. + type: + type: string + required: true + description: The type of the response. diff --git a/agent-samples/openai/OpenAIAssistants.yaml b/agent-samples/openai/OpenAIAssistants.yaml new file mode 100644 index 0000000000..c5cbb4ccd1 --- /dev/null +++ b/agent-samples/openai/OpenAIAssistants.yaml @@ -0,0 +1,30 @@ +kind: Prompt +name: Assistant +description: Helpful assistant +instructions: You are a helpful assistant. You answer questions is the language specified by the user. You return your answers in a JSON format. You must include Assistants as the type in your response. +model: + id: =Env.OPENAI_MODEL + provider: OpenAI + apiType: Assistants + options: + temperature: 0.9 + topP: 0.95 + connection: + kind: ApiKey + key: =Env.OPENAI_APIKEY +outputSchema: + name: AssistantResponse + description: The response from the assistant. + properties: + language: + type: string + required: true + description: The language of the answer. + answer: + type: string + required: true + description: The answer text. + type: + type: string + required: true + description: The type of the response. diff --git a/agent-samples/openai/OpenAIResponses.yaml b/agent-samples/openai/OpenAIResponses.yaml new file mode 100644 index 0000000000..105ade1cf6 --- /dev/null +++ b/agent-samples/openai/OpenAIResponses.yaml @@ -0,0 +1,28 @@ +kind: Prompt +name: Assistant +description: Helpful assistant +instructions: You are a helpful assistant. You answer questions is the language specified by the user. You return your answers in a JSON format. You must include Responses as the type in your response. +model: + id: =Env.OPENAI_MODEL + provider: OpenAI + apiType: Responses + options: + temperature: 0.9 + topP: 0.95 + connection: + kind: ApiKey + key: =Env.OPENAI_APIKEY +outputSchema: + properties: + language: + type: string + required: true + description: The language of the answer. + answer: + type: string + required: true + description: The answer text. + type: + type: string + required: true + description: The type of the response. diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index b17d51a149..8d19afa0c5 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -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", diff --git a/python/packages/declarative/LICENSE b/python/packages/declarative/LICENSE new file mode 100644 index 0000000000..9e841e7a26 --- /dev/null +++ b/python/packages/declarative/LICENSE @@ -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 diff --git a/python/packages/declarative/README.md b/python/packages/declarative/README.md new file mode 100644 index 0000000000..b4a97f049a --- /dev/null +++ b/python/packages/declarative/README.md @@ -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. diff --git a/python/packages/declarative/agent_framework_declarative/__init__.py b/python/packages/declarative/agent_framework_declarative/__init__.py new file mode 100644 index 0000000000..62cb297219 --- /dev/null +++ b/python/packages/declarative/agent_framework_declarative/__init__.py @@ -0,0 +1,5 @@ +# Copyright (c) Microsoft. All rights reserved. + +from ._loader import load_maml + +__all__ = ["load_maml"] diff --git a/python/packages/declarative/agent_framework_declarative/_loader.py b/python/packages/declarative/agent_framework_declarative/_loader.py new file mode 100644 index 0000000000..4cd7f6c182 --- /dev/null +++ b/python/packages/declarative/agent_framework_declarative/_loader.py @@ -0,0 +1,150 @@ +# Copyright (c) Microsoft. All rights reserved. + +from typing import Any + +import yaml + +from ._models import ( + AgentDefinition, + AgentManifest, + AnonymousConnection, + ApiKeyConnection, + ArrayProperty, + Binding, + CodeInterpreterTool, + Connection, + CustomTool, + EnvironmentVariable, + FileSearchTool, + Format, + FunctionTool, + McpServerApprovalMode, + McpServerToolAlwaysRequireApprovalMode, + McpServerToolNeverRequireApprovalMode, + McpServerToolSpecifyApprovalMode, + McpTool, + Model, + ModelOptions, + ModelResource, + ObjectProperty, + OpenApiTool, + Parser, + PromptAgent, + Property, + PropertySchema, + ProtocolVersionRecord, + ReferenceConnection, + RemoteConnection, + Resource, + Template, + ToolResource, + WebSearchTool, +) + + +def load_maml(yaml_str: str) -> Any: + """Load a MAML object from a YAML string. + + This function can parse any MAML object type and return the appropriate + Python object. The type is determined by the 'kind' field in the YAML. + If no 'kind' field is present, it's assumed to be an AgentManifest. + + Args: + yaml_str: YAML string representation of a MAML object + + Returns: + The appropriate MAML object instance, or None if the kind is not recognized + """ + as_dict = yaml.safe_load(yaml_str) + + # If no kind field, assume it's an AgentManifest + if "kind" not in as_dict: + return AgentManifest.from_dict(as_dict) + + kind = as_dict["kind"] + + # Match on the kind field to determine which class to instantiate + match kind: + # Agent types + case "Prompt": + return PromptAgent.from_dict(as_dict) + case "Agent": + return AgentDefinition.from_dict(as_dict) + + # Resource types + case "Tool": + return ToolResource.from_dict(as_dict) + case "Model": + return ModelResource.from_dict(as_dict) + case "Resource": + return Resource.from_dict(as_dict) + + # Tool types + case "function": + return FunctionTool.from_dict(as_dict) + case "custom": + return CustomTool.from_dict(as_dict) + case "web_search": + return WebSearchTool.from_dict(as_dict) + case "file_search": + return FileSearchTool.from_dict(as_dict) + case "mcp": + return McpTool.from_dict(as_dict) + case "openapi": + return OpenApiTool.from_dict(as_dict) + case "code_interpreter": + return CodeInterpreterTool.from_dict(as_dict) + + # Connection types + case "reference": + return ReferenceConnection.from_dict(as_dict) + case "remote": + return RemoteConnection.from_dict(as_dict) + case "key": + return ApiKeyConnection.from_dict(as_dict) + case "anonymous": + return AnonymousConnection.from_dict(as_dict) + case "connection": + return Connection.from_dict(as_dict) + + # Property types + case "array": + return ArrayProperty.from_dict(as_dict) + case "object": + return ObjectProperty.from_dict(as_dict) + case "property": + return Property.from_dict(as_dict) + + # MCP Server Approval Mode types + case "always": + return McpServerToolAlwaysRequireApprovalMode.from_dict(as_dict) + case "never": + return McpServerToolNeverRequireApprovalMode.from_dict(as_dict) + case "specify": + return McpServerToolSpecifyApprovalMode.from_dict(as_dict) + case "approval_mode": + return McpServerApprovalMode.from_dict(as_dict) + + # Other component types + case "binding": + return Binding.from_dict(as_dict) + case "format": + return Format.from_dict(as_dict) + case "parser": + return Parser.from_dict(as_dict) + case "template": + return Template.from_dict(as_dict) + case "model": + return Model.from_dict(as_dict) + case "model_options": + return ModelOptions.from_dict(as_dict) + case "property_schema": + return PropertySchema.from_dict(as_dict) + case "protocol_version": + return ProtocolVersionRecord.from_dict(as_dict) + case "environment_variable": + return EnvironmentVariable.from_dict(as_dict) + + # Unknown kind + case _: + return None diff --git a/python/packages/declarative/agent_framework_declarative/_models.py b/python/packages/declarative/agent_framework_declarative/_models.py new file mode 100644 index 0000000000..a72000e6b4 --- /dev/null +++ b/python/packages/declarative/agent_framework_declarative/_models.py @@ -0,0 +1,810 @@ +# Copyright (c) Microsoft. All rights reserved. + +from collections.abc import MutableMapping +from typing import Any + +from agent_framework._serialization import SerializationMixin + + +class Binding(SerializationMixin): + """Object representing a tool argument binding.""" + + def __init__( + self, + name: str = "", + input: str = "", + ) -> None: + self.name = name + self.input = input + + +class Property(SerializationMixin): + """Object representing a property in a schema.""" + + def __init__( + self, + name: str = "", + kind: str = "", + description: str | None = None, + required: bool | None = None, + default: Any | None = None, + example: Any | None = None, + enumValues: list[Any] | None = None, + ) -> None: + self.name = name + self.kind = kind + self.description = description + self.required = required + self.default = default + self.example = example + self.enumValues = enumValues or [] + + @classmethod + def from_dict( + cls, value: MutableMapping[str, Any], /, *, dependencies: MutableMapping[str, Any] | None = None + ) -> "Property": + """Create a Property instance from a dictionary, dispatching to the appropriate subclass.""" + # Only dispatch if we're being called on the base Property class + if cls is not Property: + # We're being called on a subclass, use the normal from_dict + return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[misc] + + kind = value.get("kind", "") + if kind == "array": + from agent_framework_declarative._models import ArrayProperty + + return ArrayProperty.from_dict(value, dependencies=dependencies) + if kind == "object": + from agent_framework_declarative._models import ObjectProperty + + return ObjectProperty.from_dict(value, dependencies=dependencies) + # Default to Property for kind="property" or empty + return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[misc] + + +class ArrayProperty(Property): + """Object representing an array property.""" + + def __init__( + self, + name: str = "", + kind: str = "array", + description: str | None = None, + required: bool | None = None, + default: Any | None = None, + example: Any | None = None, + enumValues: list[Any] | None = None, + items: Property | None = None, + ) -> None: + super().__init__( + name=name, + kind=kind, + description=description, + required=required, + default=default, + example=example, + enumValues=enumValues, + ) + if not isinstance(items, Property) and items is not None: + items = Property.from_dict(items) + self.items = items + + +class ObjectProperty(Property): + """Object representing an object property.""" + + def __init__( + self, + name: str = "", + kind: str = "object", + description: str | None = None, + required: bool | None = None, + default: Any | None = None, + example: Any | None = None, + enumValues: list[Any] | None = None, + properties: list[Property] | None = None, + ) -> None: + super().__init__( + name=name, + kind=kind, + description=description, + required=required, + default=default, + example=example, + enumValues=enumValues, + ) + converted_properties = [] + for prop in properties or []: + if not isinstance(prop, Property): + prop = Property.from_dict(prop) + converted_properties.append(prop) + self.properties = converted_properties + + +class PropertySchema(SerializationMixin): + """Object representing a property schema.""" + + def __init__( + self, + examples: list[dict[str, Any]] | None = None, + strict: bool = False, + properties: list[Property] | None = None, + ) -> None: + self.examples = examples or [] + self.strict = strict + converted_properties = [] + for prop in properties or []: + if not isinstance(prop, Property): + prop = Property.from_dict(prop) + converted_properties.append(prop) + self.properties = converted_properties + + @classmethod + def from_dict( + cls, value: MutableMapping[str, Any], /, *, dependencies: MutableMapping[str, Any] | None = None + ) -> "PropertySchema": + """Create a PropertySchema instance from a dictionary, filtering out 'kind' field.""" + # Filter out 'kind' and 'type' fields + kwargs = {k: v for k, v in value.items() if k not in ("type", "kind")} + return SerializationMixin.from_dict.__func__(cls, kwargs, dependencies=dependencies) # type: ignore[misc] + + +class Connection(SerializationMixin): + """Object representing a connection specification.""" + + def __init__( + self, + kind: str = "", + authenticationMode: str = "", + usageDescription: str = "", + ) -> None: + self.kind = kind + self.authenticationMode = authenticationMode + self.usageDescription = usageDescription + + @classmethod + def from_dict( + cls, value: MutableMapping[str, Any], /, *, dependencies: MutableMapping[str, Any] | None = None + ) -> "Connection": + """Create a Connection instance from a dictionary, dispatching to the appropriate subclass.""" + # Only dispatch if we're being called on the base Connection class + if cls is not Connection: + # We're being called on a subclass, use the normal from_dict + return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[misc] + + kind = value.get("kind", "") + if kind == "reference": + return SerializationMixin.from_dict.__func__( # type: ignore[misc] + ReferenceConnection, value, dependencies=dependencies + ) + if kind == "remote": + return SerializationMixin.from_dict.__func__( # type: ignore[misc] + RemoteConnection, value, dependencies=dependencies + ) + if kind == "key": + return SerializationMixin.from_dict.__func__( # type: ignore[misc] + ApiKeyConnection, value, dependencies=dependencies + ) + if kind == "anonymous": + return SerializationMixin.from_dict.__func__( # type: ignore[misc] + AnonymousConnection, value, dependencies=dependencies + ) + return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[misc] + + +class ReferenceConnection(Connection): + """Object representing a reference connection.""" + + def __init__( + self, + kind: str = "reference", + authenticationMode: str = "", + usageDescription: str = "", + name: str = "", + target: str = "", + ) -> None: + super().__init__( + kind=kind, + authenticationMode=authenticationMode, + usageDescription=usageDescription, + ) + self.name = name + self.target = target + + +class RemoteConnection(Connection): + """Object representing a remote connection.""" + + def __init__( + self, + kind: str = "remote", + authenticationMode: str = "", + usageDescription: str = "", + name: str = "", + endpoint: str = "", + ) -> None: + super().__init__( + kind=kind, + authenticationMode=authenticationMode, + usageDescription=usageDescription, + ) + self.name = name + self.endpoint = endpoint + + +class ApiKeyConnection(Connection): + """Object representing an API key connection.""" + + def __init__( + self, + kind: str = "key", + authenticationMode: str = "", + usageDescription: str = "", + endpoint: str = "", + apiKey: str = "", + ) -> None: + super().__init__( + kind=kind, + authenticationMode=authenticationMode, + usageDescription=usageDescription, + ) + self.endpoint = endpoint + self.apiKey = apiKey + + +class AnonymousConnection(Connection): + """Object representing an anonymous connection.""" + + def __init__( + self, + kind: str = "anonymous", + authenticationMode: str = "", + usageDescription: str = "", + endpoint: str = "", + ) -> None: + super().__init__( + kind=kind, + authenticationMode=authenticationMode, + usageDescription=usageDescription, + ) + self.endpoint = endpoint + + +class ModelOptions(SerializationMixin): + """Object representing model options.""" + + def __init__( + self, + frequencyPenalty: float | None = None, + maxOutputTokens: int | None = None, + presencePenalty: float | None = None, + seed: int | None = None, + temperature: float | None = None, + topK: int | None = None, + topP: float | None = None, + stopSequences: list[str] | None = None, + allowMultipleToolCalls: bool | None = None, + additionalProperties: dict[str, Any] | None = None, + ) -> None: + self.frequencyPenalty = frequencyPenalty + self.maxOutputTokens = maxOutputTokens + self.presencePenalty = presencePenalty + self.seed = seed + self.temperature = temperature + self.topK = topK + self.topP = topP + self.stopSequences = stopSequences or [] + self.allowMultipleToolCalls = allowMultipleToolCalls + self.additionalProperties = additionalProperties or {} + + +class Model(SerializationMixin): + """Object representing a model specification.""" + + def __init__( + self, + id: str = "", + provider: str = "", + apiType: str = "", + connection: Connection | None = None, + options: ModelOptions | None = None, + ) -> None: + self.id = id + self.provider = provider + self.apiType = apiType + if not isinstance(connection, Connection) and connection is not None: + connection = Connection.from_dict(connection) + self.connection = connection + if not isinstance(options, ModelOptions) and options is not None: + options = ModelOptions.from_dict(options) + self.options = options + + +class Format(SerializationMixin): + """Object representing template format.""" + + def __init__( + self, + kind: str = "", + strict: bool = False, + options: dict[str, Any] | None = None, + ) -> None: + self.kind = kind + self.strict = strict + self.options = options or {} + + +class Parser(SerializationMixin): + """Object representing template parser.""" + + def __init__( + self, + kind: str = "", + options: dict[str, Any] | None = None, + ) -> None: + self.kind = kind + self.options = options or {} + + +class Template(SerializationMixin): + """Object representing a template configuration.""" + + def __init__( + self, + format: Format | None = None, + parser: Parser | None = None, + ) -> None: + if not isinstance(format, Format) and format is not None: + format = Format.from_dict(format) + self.format = format + if not isinstance(parser, Parser) and parser is not None: + parser = Parser.from_dict(parser) + self.parser = parser + + +class AgentDefinition(SerializationMixin): + """Object representing a prompt specification.""" + + def __init__( + self, + kind: str = "", + name: str = "", + displayName: str = "", + description: str = "", + metadata: dict[str, Any] | None = None, + inputSchema: PropertySchema | None = None, + outputSchema: PropertySchema | None = None, + ) -> None: + self.kind = kind + self.name = name + self.displayName = displayName + self.description = description + self.metadata = metadata + if not isinstance(inputSchema, PropertySchema) and inputSchema is not None: + inputSchema = PropertySchema.from_dict(inputSchema) + self.inputSchema = inputSchema + if not isinstance(outputSchema, PropertySchema) and outputSchema is not None: + outputSchema = PropertySchema.from_dict(outputSchema) + self.outputSchema = outputSchema + + @classmethod + def from_dict( + cls, value: MutableMapping[str, Any], /, *, dependencies: MutableMapping[str, Any] | None = None + ) -> "AgentDefinition": + """Create an AgentDefinition instance from a dictionary, dispatching to the appropriate subclass.""" + # Only dispatch if we're being called on the base AgentDefinition class + if cls is not AgentDefinition: + # We're being called on a subclass, use the normal from_dict + return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[misc] + + kind = value.get("kind", "") + if kind == "Prompt" or kind == "Agent": + from agent_framework_declarative._models import PromptAgent + + return PromptAgent.from_dict(value, dependencies=dependencies) + # Default to AgentDefinition + return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[misc] + + +class Tool(SerializationMixin): + """Base class for tools.""" + + def __init__( + self, + name: str = "", + kind: str = "", + description: str = "", + bindings: list[Binding] | None = None, + ) -> None: + self.name = name + self.kind = kind + self.description = description + converted_bindings = [] + for binding in bindings or []: + if not isinstance(binding, Binding): + binding = Binding.from_dict(binding) + converted_bindings.append(binding) + self.bindings = converted_bindings + + +class FunctionTool(Tool): + """Object representing a function tool.""" + + def __init__( + self, + name: str = "", + kind: str = "function", + description: str = "", + bindings: list[Binding] | None = None, + parameters: PropertySchema | None = None, + strict: bool = False, + ) -> None: + super().__init__( + name=name, + kind=kind, + description=description, + bindings=bindings, + ) + if not isinstance(parameters, PropertySchema) and parameters is not None: + parameters = PropertySchema.from_dict(parameters) + self.parameters = parameters + self.strict = strict + + +class CustomTool(Tool): + """Object representing a custom tool.""" + + def __init__( + self, + name: str = "", + kind: str = "custom", + description: str = "", + bindings: list[Binding] | None = None, + connection: Connection | None = None, + options: dict[str, Any] | None = None, + ) -> None: + super().__init__( + name=name, + kind=kind, + description=description, + bindings=bindings, + ) + if not isinstance(connection, Connection) and connection is not None: + connection = Connection.from_dict(connection) + self.connection = connection + self.options = options or {} + + +class WebSearchTool(Tool): + """Object representing a web search tool.""" + + def __init__( + self, + name: str = "", + kind: str = "web_search", + description: str = "", + bindings: list[Binding] | None = None, + connection: Connection | None = None, + options: dict[str, Any] | None = None, + ) -> None: + super().__init__( + name=name, + kind=kind, + description=description, + bindings=bindings, + ) + if not isinstance(connection, Connection) and connection is not None: + connection = Connection.from_dict(connection) + self.connection = connection + self.options = options or {} + + +class FileSearchTool(Tool): + """Object representing a file search tool.""" + + def __init__( + self, + name: str = "", + kind: str = "file_search", + description: str = "", + bindings: list[Binding] | None = None, + connection: Connection | None = None, + vectorStoreIds: list[str] | None = None, + maximumResultCount: int | None = None, + ranker: str | None = None, + scoreThreshold: float | None = None, + filters: dict[str, Any] | None = None, + ) -> None: + super().__init__( + name=name, + kind=kind, + description=description, + bindings=bindings, + ) + if not isinstance(connection, Connection) and connection is not None: + connection = Connection.from_dict(connection) + self.connection = connection + self.vectorStoreIds = vectorStoreIds or [] + self.maximumResultCount = maximumResultCount + self.ranker = ranker + self.scoreThreshold = scoreThreshold + self.filters = filters or {} + + +class McpServerApprovalMode(SerializationMixin): + """Base class for MCP server approval modes.""" + + def __init__( + self, + kind: str = "", + ) -> None: + self.kind = kind + + +class McpServerToolAlwaysRequireApprovalMode(McpServerApprovalMode): + """MCP server tool always require approval mode.""" + + def __init__( + self, + kind: str = "always", + ) -> None: + super().__init__(kind=kind) + + +class McpServerToolNeverRequireApprovalMode(McpServerApprovalMode): + """MCP server tool never require approval mode.""" + + def __init__( + self, + kind: str = "never", + ) -> None: + super().__init__(kind=kind) + + +class McpServerToolSpecifyApprovalMode(McpServerApprovalMode): + """MCP server tool specify approval mode.""" + + def __init__( + self, + kind: str = "specify", + alwaysRequireApprovalTools: list[str] | None = None, + neverRequireApprovalTools: list[str] | None = None, + ) -> None: + super().__init__(kind=kind) + self.alwaysRequireApprovalTools = alwaysRequireApprovalTools or [] + self.neverRequireApprovalTools = neverRequireApprovalTools or [] + + +class McpTool(Tool): + """Object representing an MCP tool.""" + + def __init__( + self, + name: str = "", + kind: str = "mcp", + description: str = "", + bindings: list[Binding] | None = None, + connection: Connection | None = None, + serverName: str = "", + serverDescription: str = "", + approvalMode: McpServerApprovalMode | None = None, + allowedTools: list[str] | None = None, + ) -> None: + super().__init__( + name=name, + kind=kind, + description=description, + bindings=bindings, + ) + if not isinstance(connection, Connection) and connection is not None: + connection = Connection.from_dict(connection) + self.connection = connection + self.serverName = serverName + self.serverDescription = serverDescription + if not isinstance(approvalMode, McpServerApprovalMode) and approvalMode is not None: + approvalMode = McpServerApprovalMode.from_dict(approvalMode) + self.approvalMode = approvalMode + self.allowedTools = allowedTools or [] + + +class OpenApiTool(Tool): + """Object representing an OpenAPI tool.""" + + def __init__( + self, + name: str = "", + kind: str = "openapi", + description: str = "", + bindings: list[Binding] | None = None, + connection: Connection | None = None, + specification: str = "", + ) -> None: + super().__init__( + name=name, + kind=kind, + description=description, + bindings=bindings, + ) + if not isinstance(connection, Connection) and connection is not None: + connection = Connection.from_dict(connection) + self.connection = connection + self.specification = specification + + +class CodeInterpreterTool(Tool): + """Object representing a code interpreter tool.""" + + def __init__( + self, + name: str = "", + kind: str = "code_interpreter", + description: str = "", + bindings: list[Binding] | None = None, + fileIds: list[str] | None = None, + ) -> None: + super().__init__( + name=name, + kind=kind, + description=description, + bindings=bindings, + ) + self.fileIds = fileIds or [] + + +class PromptAgent(AgentDefinition): + """Object representing a prompt agent specification.""" + + def __init__( + self, + kind: str = "Prompt", + name: str = "", + displayName: str = "", + description: str = "", + metadata: dict[str, Any] | None = None, + inputSchema: PropertySchema | None = None, + outputSchema: PropertySchema | None = None, + model: Model | None = None, + tools: list[Tool] | None = None, + template: Template | None = None, + instructions: str = "", + additionalInstructions: str = "", + ) -> None: + super().__init__( + kind=kind, + name=name, + displayName=displayName, + description=description, + metadata=metadata, + inputSchema=inputSchema, + outputSchema=outputSchema, + ) + if not isinstance(model, Model) and model is not None: + model = Model.from_dict(model) + self.model = model + converted_tools = [] + for tool in tools or []: + if not isinstance(tool, Tool): + tool = Tool.from_dict(tool) + converted_tools.append(tool) + self.tools = converted_tools + if not isinstance(template, Template) and template is not None: + template = Template.from_dict(template) + self.template = template + self.instructions = instructions + self.additionalInstructions = additionalInstructions + + +class Resource(SerializationMixin): + """Object representing a resource.""" + + def __init__( + self, + name: str = "", + kind: str = "", + ) -> None: + self.name = name + self.kind = kind + + @classmethod + def from_dict( + cls, value: MutableMapping[str, Any], /, *, dependencies: MutableMapping[str, Any] | None = None + ) -> "Resource": + """Create a Resource instance from a dictionary, dispatching to the appropriate subclass.""" + # Only dispatch if we're being called on the base Resource class + if cls is not Resource: + # We're being called on a subclass, use the normal from_dict + return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[misc] + + kind = value.get("kind", "") + if kind == "model": + return SerializationMixin.from_dict.__func__( # type: ignore[misc] + ModelResource, value, dependencies=dependencies + ) + if kind == "tool": + return SerializationMixin.from_dict.__func__( # type: ignore[misc] + ToolResource, value, dependencies=dependencies + ) + return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[misc] + + +class ModelResource(Resource): + """Object representing a model resource.""" + + def __init__( + self, + kind: str = "model", + name: str = "", + id: str = "", + ) -> None: + super().__init__(kind=kind, name=name) + self.id = id + + +class ToolResource(Resource): + """Object representing a tool resource.""" + + def __init__( + self, + kind: str = "tool", + name: str = "", + id: str = "", + options: dict[str, Any] | None = None, + ) -> None: + super().__init__(kind=kind, name=name) + self.id = id + self.options = options or {} + + +class ProtocolVersionRecord(SerializationMixin): + """Object representing a protocol version record.""" + + def __init__( + self, + protocol: str = "", + version: str = "", + ) -> None: + self.protocol = protocol + self.version = version + + +class EnvironmentVariable(SerializationMixin): + """Object representing an environment variable.""" + + def __init__( + self, + name: str = "", + value: str = "", + ) -> None: + self.name = name + self.value = value + + +class AgentManifest(SerializationMixin): + """Object representing an agent manifest.""" + + def __init__( + self, + name: str = "", + displayName: str = "", + description: str = "", + metadata: dict[str, Any] | None = None, + template: AgentDefinition | None = None, + parameters: PropertySchema | None = None, + resources: list[Resource] | None = None, + ) -> None: + self.name = name + self.displayName = displayName + self.description = description + self.metadata = metadata or {} + if not isinstance(template, AgentDefinition) and template is not None: + template = AgentDefinition.from_dict(template) + self.template = template or AgentDefinition() + if not isinstance(parameters, PropertySchema) and parameters is not None: + parameters = PropertySchema.from_dict(parameters) + self.parameters = parameters or PropertySchema() + converted_resources = [] + for resource in resources or []: + if not isinstance(resource, Resource): + resource = Resource.from_dict(resource) + converted_resources.append(resource) + self.resources = converted_resources diff --git a/python/packages/declarative/pyproject.toml b/python/packages/declarative/pyproject.toml new file mode 100644 index 0000000000..98109ebae5 --- /dev/null +++ b/python/packages/declarative/pyproject.toml @@ -0,0 +1,88 @@ +[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", + "pyyaml>=6.0,<7.0", +] + +[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 + +[tool.bandit] +targets = ["agent_framework_anthropic"] +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_anthropic" +test = "pytest --cov=agent_framework_anthropic --cov-report=term-missing:skip-covered tests" + +[build-system] +requires = ["flit-core >= 3.11,<4.0"] +build-backend = "flit_core.buildapi" diff --git a/python/packages/declarative/tests/test_loader.py b/python/packages/declarative/tests/test_loader.py new file mode 100644 index 0000000000..9a1b8724cd --- /dev/null +++ b/python/packages/declarative/tests/test_loader.py @@ -0,0 +1,422 @@ +# Copyright (c) Microsoft. All rights reserved. + +import pytest +from agent_framework_declarative._agent_factory import load_maml + +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, +) + + +@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_load_maml_all_types(yaml_content, expected_type, expected_attributes): + """Test that load_maml correctly loads all MAML object types.""" + result = load_maml(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_load_maml_unknown_kind(): + """Test that load_maml returns None for unknown kind.""" + yaml_content = """ +kind: unknown_type +name: test +""" + result = load_maml(yaml_content) + assert result is None + + +def test_load_maml_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 = load_maml(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_load_maml_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 = load_maml(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_load_maml_model_resource(): + """Test loading a model resource.""" + yaml_content = """ +kind: Model +name: my-model +id: gpt-4 +""" + result = load_maml(yaml_content) + + assert isinstance(result, ModelResource) + assert result.id == "gpt-4" + + +def test_load_maml_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 = load_maml(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" diff --git a/python/packages/declarative/tests/test_models.py b/python/packages/declarative/tests/test_models.py new file mode 100644 index 0000000000..c7d97b412d --- /dev/null +++ b/python/packages/declarative/tests/test_models.py @@ -0,0 +1,778 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for MAML model classes.""" + +from agent_framework_declarative._models import ( + AgentDefinition, + AgentManifest, + AnonymousConnection, + ApiKeyConnection, + ArrayProperty, + Binding, + CodeInterpreterTool, + Connection, + CustomTool, + EnvironmentVariable, + FileSearchTool, + Format, + FunctionTool, + McpServerApprovalMode, + McpServerToolAlwaysRequireApprovalMode, + McpServerToolNeverRequireApprovalMode, + McpServerToolSpecifyApprovalMode, + McpTool, + Model, + ModelOptions, + ModelResource, + ObjectProperty, + OpenApiTool, + Parser, + PromptAgent, + Property, + PropertySchema, + ProtocolVersionRecord, + ReferenceConnection, + RemoteConnection, + Resource, + Template, + ToolResource, + WebSearchTool, +) + + +class TestBinding: + """Tests for Binding class.""" + + def test_binding_creation(self): + binding = Binding(name="arg1", input="value1") + assert binding.name == "arg1" + assert binding.input == "value1" + + def test_binding_from_dict(self): + data = {"name": "arg1", "input": "value1"} + binding = Binding.from_dict(data) + assert binding.name == "arg1" + assert binding.input == "value1" + + def test_binding_to_dict(self): + binding = Binding(name="arg1", input="value1") + result = binding.to_dict() + assert result["name"] == "arg1" + assert result["input"] == "value1" + + +class TestProperty: + """Tests for Property class.""" + + def test_property_creation(self): + prop = Property( + name="test_prop", + kind="string", + description="A test property", + required=True, + default="default_value", + example="example_value", + enumValues=["val1", "val2"], + ) + assert prop.name == "test_prop" + assert prop.kind == "string" + assert prop.description == "A test property" + assert prop.required is True + assert prop.default == "default_value" + assert prop.example == "example_value" + assert prop.enumValues == ["val1", "val2"] + + def test_property_from_dict(self): + data = { + "name": "test_prop", + "kind": "string", + "description": "A test property", + "required": True, + } + prop = Property.from_dict(data) + assert prop.name == "test_prop" + assert prop.kind == "string" + assert prop.description == "A test property" + assert prop.required is True + + +class TestArrayProperty: + """Tests for ArrayProperty class.""" + + def test_array_property_creation(self): + items = Property(name="item", kind="string") + array_prop = ArrayProperty(name="test_array", kind="array", items=items, required=True) + assert array_prop.name == "test_array" + assert array_prop.kind == "array" + assert array_prop.items.name == "item" + assert array_prop.required is True + + def test_array_property_from_dict(self): + data = { + "name": "test_array", + "kind": "array", + "items": {"name": "item", "kind": "string"}, + "required": True, + } + array_prop = ArrayProperty.from_dict(data) + assert array_prop.name == "test_array" + assert array_prop.kind == "array" + assert isinstance(array_prop.items, Property) + assert array_prop.items.name == "item" + + +class TestObjectProperty: + """Tests for ObjectProperty class.""" + + def test_object_property_creation(self): + props = [ + Property(name="prop1", kind="string"), + Property(name="prop2", kind="integer"), + ] + obj_prop = ObjectProperty(name="test_object", kind="object", properties=props, required=True) + assert obj_prop.name == "test_object" + assert obj_prop.kind == "object" + assert len(obj_prop.properties) == 2 + assert obj_prop.properties[0].name == "prop1" + + def test_object_property_from_dict(self): + data = { + "name": "test_object", + "kind": "object", + "properties": [ + {"name": "prop1", "kind": "string"}, + {"name": "prop2", "kind": "integer"}, + ], + "required": True, + } + obj_prop = ObjectProperty.from_dict(data) + assert obj_prop.name == "test_object" + assert obj_prop.kind == "object" + assert len(obj_prop.properties) == 2 + assert all(isinstance(p, Property) for p in obj_prop.properties) + + +class TestPropertySchema: + """Tests for PropertySchema class.""" + + def test_property_schema_creation(self): + props = [Property(name="prop1", kind="string")] + schema = PropertySchema(properties=props, strict=True) + assert schema.strict is True + assert len(schema.properties) == 1 + + def test_property_schema_from_dict(self): + data = { + "strict": False, + "properties": [{"name": "prop1", "kind": "string"}], + } + schema = PropertySchema.from_dict(data) + assert schema.strict is False + assert len(schema.properties) == 1 + # Properties are properly converted to Property instances + assert isinstance(schema.properties[0], Property) + assert schema.properties[0].name == "prop1" + assert schema.properties[0].kind == "string" + + +class TestConnection: + """Tests for Connection base class.""" + + def test_connection_creation(self): + conn = Connection(kind="base") + assert conn.kind == "base" + + def test_connection_from_dict(self): + data = {"kind": "base"} + conn = Connection.from_dict(data) + assert conn.kind == "base" + + +class TestReferenceConnection: + """Tests for ReferenceConnection class.""" + + def test_reference_connection_creation(self): + conn = ReferenceConnection(name="my-connection", target="target-connection") + assert conn.kind == "reference" + assert conn.name == "my-connection" + assert conn.target == "target-connection" + + def test_reference_connection_from_dict(self): + data = {"kind": "reference", "name": "my-connection", "target": "target-connection"} + conn = ReferenceConnection.from_dict(data) + assert conn.kind == "reference" + assert conn.name == "my-connection" + assert conn.target == "target-connection" + + +class TestRemoteConnection: + """Tests for RemoteConnection class.""" + + def test_remote_connection_creation(self): + conn = RemoteConnection(name="my-remote", endpoint="https://api.example.com") + assert conn.kind == "remote" + assert conn.endpoint == "https://api.example.com" + + def test_remote_connection_from_dict(self): + data = {"kind": "remote", "endpoint": "https://api.example.com"} + conn = RemoteConnection.from_dict(data) + assert conn.kind == "remote" + assert conn.endpoint == "https://api.example.com" + + +class TestApiKeyConnection: + """Tests for ApiKeyConnection class.""" + + def test_api_key_connection_creation(self): + conn = ApiKeyConnection(apiKey="secret-key", endpoint="https://api.example.com") + assert conn.kind == "key" + assert conn.apiKey == "secret-key" + assert conn.endpoint == "https://api.example.com" + + def test_api_key_connection_from_dict(self): + data = {"kind": "key", "apiKey": "secret-key", "endpoint": "https://api.example.com"} + conn = ApiKeyConnection.from_dict(data) + assert conn.kind == "key" + assert conn.apiKey == "secret-key" + + +class TestAnonymousConnection: + """Tests for AnonymousConnection class.""" + + def test_anonymous_connection_creation(self): + conn = AnonymousConnection(endpoint="https://api.example.com") + assert conn.kind == "anonymous" + assert conn.endpoint == "https://api.example.com" + + def test_anonymous_connection_from_dict(self): + data = {"kind": "anonymous", "endpoint": "https://api.example.com"} + conn = AnonymousConnection.from_dict(data) + assert conn.kind == "anonymous" + assert conn.endpoint == "https://api.example.com" + + +class TestModelOptions: + """Tests for ModelOptions class.""" + + def test_model_options_creation(self): + options = ModelOptions(temperature=0.7, maxOutputTokens=1000, topP=0.9) + assert options.temperature == 0.7 + assert options.maxOutputTokens == 1000 + assert options.topP == 0.9 + + def test_model_options_from_dict(self): + data = {"temperature": 0.7, "maxOutputTokens": 1000, "topP": 0.9} + options = ModelOptions.from_dict(data) + assert options.temperature == 0.7 + assert options.maxOutputTokens == 1000 + assert options.topP == 0.9 + + +class TestModel: + """Tests for Model class.""" + + def test_model_creation(self): + model = Model(id="gpt-4", provider="openai") + assert model.id == "gpt-4" + assert model.provider == "openai" + + def test_model_from_dict(self): + data = {"id": "gpt-4", "provider": "openai"} + model = Model.from_dict(data) + assert model.id == "gpt-4" + assert model.provider == "openai" + + def test_model_with_connection(self): + data = { + "id": "gpt-4", + "connection": {"kind": "reference", "name": "my-connection"}, + } + model = Model.from_dict(data) + assert model.id == "gpt-4" + assert model.connection.kind == "reference" + + +class TestFormat: + """Tests for Format class.""" + + def test_format_creation(self): + fmt = Format(kind="json", strict=True, options={"type": "object"}) + assert fmt.kind == "json" + assert fmt.strict is True + assert fmt.options == {"type": "object"} + + def test_format_from_dict(self): + data = {"kind": "json", "strict": False, "options": {"type": "object"}} + fmt = Format.from_dict(data) + assert fmt.kind == "json" + assert fmt.strict is False + + +class TestParser: + """Tests for Parser class.""" + + def test_parser_creation(self): + parser = Parser(kind="json", options={"strict": True}) + assert parser.kind == "json" + assert parser.options == {"strict": True} + + def test_parser_from_dict(self): + data = {"kind": "json", "options": {"strict": True}} + parser = Parser.from_dict(data) + assert parser.kind == "json" + assert parser.options == {"strict": True} + + +class TestTemplate: + """Tests for Template class.""" + + def test_template_creation(self): + template = Template( + format=Format(kind="text"), + parser=Parser(kind="text"), + ) + assert isinstance(template.format, Format) + assert isinstance(template.parser, Parser) + + def test_template_from_dict(self): + data = { + "format": {"kind": "text"}, + "parser": {"kind": "text"}, + } + template = Template.from_dict(data) + assert isinstance(template.format, Format) + assert isinstance(template.parser, Parser) + + +class TestAgentDefinition: + """Tests for AgentDefinition class.""" + + def test_agent_definition_creation(self): + agent = AgentDefinition( + name="test-agent", + description="A test agent", + ) + assert agent.name == "test-agent" + assert agent.description == "A test agent" + + def test_agent_definition_from_dict(self): + data = { + "name": "test-agent", + "description": "A test agent", + } + agent = AgentDefinition.from_dict(data) + assert agent.name == "test-agent" + assert agent.description == "A test agent" + + +class TestFunctionTool: + """Tests for FunctionTool class.""" + + def test_function_tool_creation(self): + tool = FunctionTool( + name="my_function", + description="A test function", + kind="function", + ) + assert tool.name == "my_function" + assert tool.kind == "function" + + def test_function_tool_from_dict(self): + data = { + "name": "my_function", + "description": "A test function", + "kind": "function", + "parameters": {"strict": False, "properties": []}, + } + tool = FunctionTool.from_dict(data) + assert tool.name == "my_function" + assert tool.kind == "function" + assert isinstance(tool.parameters, PropertySchema) + + +class TestCustomTool: + """Tests for CustomTool class.""" + + def test_custom_tool_creation(self): + tool = CustomTool( + name="custom_tool", + description="A custom tool", + kind="custom", + options={"endpoint": "https://tool.example.com"}, + ) + assert tool.name == "custom_tool" + assert tool.kind == "custom" + assert tool.options == {"endpoint": "https://tool.example.com"} + + def test_custom_tool_from_dict(self): + data = { + "name": "custom_tool", + "description": "A custom tool", + "kind": "custom", + "options": {"endpoint": "https://tool.example.com"}, + } + tool = CustomTool.from_dict(data) + assert tool.name == "custom_tool" + assert tool.kind == "custom" + + +class TestWebSearchTool: + """Tests for WebSearchTool class.""" + + def test_web_search_tool_creation(self): + tool = WebSearchTool( + name="web_search", + description="Search the web", + kind="web_search", + options={"maxResults": 10}, + ) + assert tool.name == "web_search" + assert tool.kind == "web_search" + assert tool.options == {"maxResults": 10} + + def test_web_search_tool_from_dict(self): + data = { + "name": "web_search", + "description": "Search the web", + "kind": "web_search", + "options": {"maxResults": 10}, + } + tool = WebSearchTool.from_dict(data) + assert tool.name == "web_search" + assert tool.kind == "web_search" + assert tool.options == {"maxResults": 10} + + +class TestFileSearchTool: + """Tests for FileSearchTool class.""" + + def test_file_search_tool_creation(self): + tool = FileSearchTool( + name="file_search", + description="Search files", + kind="file_search", + vectorStoreIds=["vs1", "vs2"], + ) + assert tool.name == "file_search" + assert tool.kind == "file_search" + assert tool.vectorStoreIds == ["vs1", "vs2"] + + def test_file_search_tool_from_dict(self): + data = { + "name": "file_search", + "description": "Search files", + "kind": "file_search", + "vectorStoreIds": ["vs1", "vs2"], + } + tool = FileSearchTool.from_dict(data) + assert tool.name == "file_search" + assert tool.kind == "file_search" + assert tool.vectorStoreIds == ["vs1", "vs2"] + + +class TestMcpServerApprovalMode: + """Tests for MCP Server Approval Mode classes.""" + + def test_always_approval_mode(self): + mode = McpServerToolAlwaysRequireApprovalMode() + assert mode.kind == "always" + + def test_always_approval_mode_from_dict(self): + data = {"kind": "always"} + mode = McpServerToolAlwaysRequireApprovalMode.from_dict(data) + assert mode.kind == "always" + + def test_never_approval_mode(self): + mode = McpServerToolNeverRequireApprovalMode() + assert mode.kind == "never" + + def test_never_approval_mode_from_dict(self): + data = {"kind": "never"} + mode = McpServerToolNeverRequireApprovalMode.from_dict(data) + assert mode.kind == "never" + + def test_specify_approval_mode(self): + mode = McpServerToolSpecifyApprovalMode( + alwaysRequireApprovalTools=["tool1"], + neverRequireApprovalTools=["tool2"], + ) + assert mode.kind == "specify" + assert mode.alwaysRequireApprovalTools == ["tool1"] + assert mode.neverRequireApprovalTools == ["tool2"] + + def test_specify_approval_mode_from_dict(self): + data = { + "kind": "specify", + "alwaysRequireApprovalTools": ["tool1"], + "neverRequireApprovalTools": ["tool2"], + } + mode = McpServerToolSpecifyApprovalMode.from_dict(data) + assert mode.kind == "specify" + assert mode.alwaysRequireApprovalTools == ["tool1"] + assert mode.neverRequireApprovalTools == ["tool2"] + + +class TestMcpTool: + """Tests for McpTool class.""" + + def test_mcp_tool_creation(self): + tool = McpTool( + name="mcp_tool", + description="An MCP tool", + kind="mcp", + serverName="test-server", + ) + assert tool.name == "mcp_tool" + assert tool.kind == "mcp" + assert tool.serverName == "test-server" + + def test_mcp_tool_from_dict(self): + data = { + "name": "mcp_tool", + "description": "An MCP tool", + "kind": "mcp", + "serverName": "test-server", + "approvalMode": {"kind": "always"}, + } + tool = McpTool.from_dict(data) + assert tool.name == "mcp_tool" + assert tool.kind == "mcp" + assert isinstance(tool.approvalMode, McpServerApprovalMode) + + +class TestOpenApiTool: + """Tests for OpenApiTool class.""" + + def test_openapi_tool_creation(self): + tool = OpenApiTool( + name="openapi_tool", + description="An OpenAPI tool", + kind="openapi", + specification="https://api.example.com/openapi.json", + ) + assert tool.name == "openapi_tool" + assert tool.kind == "openapi" + assert tool.specification == "https://api.example.com/openapi.json" + + def test_openapi_tool_from_dict(self): + data = { + "name": "openapi_tool", + "description": "An OpenAPI tool", + "kind": "openapi", + "specification": "https://api.example.com/openapi.json", + } + tool = OpenApiTool.from_dict(data) + assert tool.name == "openapi_tool" + assert tool.kind == "openapi" + + +class TestCodeInterpreterTool: + """Tests for CodeInterpreterTool class.""" + + def test_code_interpreter_tool_creation(self): + tool = CodeInterpreterTool( + name="code_interpreter", + description="Execute code", + kind="code_interpreter", + fileIds=["file1", "file2"], + ) + assert tool.name == "code_interpreter" + assert tool.kind == "code_interpreter" + assert tool.fileIds == ["file1", "file2"] + + def test_code_interpreter_tool_from_dict(self): + data = { + "name": "code_interpreter", + "description": "Execute code", + "kind": "code_interpreter", + "fileIds": ["file1", "file2"], + } + tool = CodeInterpreterTool.from_dict(data) + assert tool.name == "code_interpreter" + assert tool.kind == "code_interpreter" + assert tool.fileIds == ["file1", "file2"] + + +class TestPromptAgent: + """Tests for PromptAgent class.""" + + def test_prompt_agent_creation(self): + agent = PromptAgent( + name="prompt-agent", + description="A prompt-based agent", + instructions="You are a helpful assistant", + kind="Prompt", + ) + assert agent.name == "prompt-agent" + assert agent.kind == "Prompt" + assert agent.instructions == "You are a helpful assistant" + + def test_prompt_agent_from_dict(self): + data = { + "name": "prompt-agent", + "description": "A prompt-based agent", + "instructions": "You are a helpful assistant", + "kind": "Prompt", + "model": {"id": "gpt-4"}, + } + agent = PromptAgent.from_dict(data) + assert agent.name == "prompt-agent" + assert isinstance(agent.model, Model) + assert isinstance(agent.model, Model) + + def test_prompt_agent_with_tools(self): + data = { + "name": "prompt-agent", + "kind": "Prompt", + "tools": [ + {"name": "search", "kind": "web_search"}, + {"name": "calc", "kind": "function"}, + ], + } + agent = PromptAgent.from_dict(data) + assert len(agent.tools) == 2 + # Tools are converted via Tool.from_dict, type depends on 'kind' + assert agent.tools[0].kind == "web_search" + assert agent.tools[1].kind == "function" + + +class TestResource: + """Tests for Resource base class.""" + + def test_resource_creation(self): + resource = Resource(name="test-resource", kind="Resource") + assert resource.name == "test-resource" + assert resource.kind == "Resource" + + def test_resource_from_dict(self): + data = {"name": "test-resource", "kind": "Resource"} + resource = Resource.from_dict(data) + assert resource.name == "test-resource" + + +class TestModelResource: + """Tests for ModelResource class.""" + + def test_model_resource_creation(self): + resource = ModelResource(name="my-model", kind="model", id="gpt-4") + assert resource.name == "my-model" + assert resource.kind == "model" + assert resource.id == "gpt-4" + + def test_model_resource_from_dict(self): + data = { + "name": "my-model", + "kind": "model", + "id": "gpt-4", + } + resource = ModelResource.from_dict(data) + assert resource.name == "my-model" + assert resource.kind == "model" + assert resource.id == "gpt-4" + + +class TestToolResource: + """Tests for ToolResource class.""" + + def test_tool_resource_creation(self): + resource = ToolResource(name="my-tool", kind="tool", id="search-tool") + assert resource.name == "my-tool" + assert resource.kind == "tool" + assert resource.id == "search-tool" + + def test_tool_resource_from_dict(self): + data = { + "name": "my-tool", + "kind": "tool", + "id": "search-tool", + } + resource = ToolResource.from_dict(data) + assert resource.name == "my-tool" + assert resource.kind == "tool" + assert resource.id == "search-tool" + + +class TestProtocolVersionRecord: + """Tests for ProtocolVersionRecord class.""" + + def test_protocol_version_record_creation(self): + record = ProtocolVersionRecord(protocol="mcp", version="1.0.0") + assert record.protocol == "mcp" + assert record.version == "1.0.0" + + def test_protocol_version_record_from_dict(self): + data = {"protocol": "mcp", "version": "1.0.0"} + record = ProtocolVersionRecord.from_dict(data) + assert record.protocol == "mcp" + assert record.version == "1.0.0" + + +class TestEnvironmentVariable: + """Tests for EnvironmentVariable class.""" + + def test_environment_variable_creation(self): + env_var = EnvironmentVariable(name="API_KEY", value="secret123") + assert env_var.name == "API_KEY" + assert env_var.value == "secret123" + + def test_environment_variable_from_dict(self): + data = {"name": "API_KEY", "value": "secret123"} + env_var = EnvironmentVariable.from_dict(data) + assert env_var.name == "API_KEY" + assert env_var.value == "secret123" + + +class TestAgentManifest: + """Tests for AgentManifest class.""" + + def test_agent_manifest_creation(self): + manifest = AgentManifest(name="my-agent-manifest", description="A test manifest") + assert manifest.name == "my-agent-manifest" + assert manifest.description == "A test manifest" + + def test_agent_manifest_from_dict(self): + data = { + "name": "my-agent-manifest", + "description": "A test manifest", + } + manifest = AgentManifest.from_dict(data) + assert manifest.name == "my-agent-manifest" + + def test_agent_manifest_with_resources(self): + data = { + "name": "my-agent-manifest", + "resources": [ + {"name": "model1", "kind": "model", "id": "gpt-4"}, + { + "name": "tool1", + "kind": "tool", + "id": "search-tool", + }, + ], + } + manifest = AgentManifest.from_dict(data) + assert manifest.name == "my-agent-manifest" + assert len(manifest.resources) == 2 + # Resources are converted via Resource.from_dict based on their 'kind' + assert isinstance(manifest.resources[0], ModelResource) + assert isinstance(manifest.resources[1], ToolResource) + + def test_agent_manifest_complete(self): + """Test a complete agent manifest with all fields.""" + data = { + "name": "complete-manifest", + "description": "A complete test manifest", + "template": { + "name": "assistant", + "kind": "Prompt", + "description": "A helpful assistant", + }, + "resources": [ + {"name": "model1", "kind": "model", "id": "gpt-4"}, + ], + } + manifest = AgentManifest.from_dict(data) + assert manifest.name == "complete-manifest" + assert isinstance(manifest.template, AgentDefinition) + assert len(manifest.resources) == 1 + assert isinstance(manifest.resources[0], ModelResource) diff --git a/python/pyproject.toml b/python/pyproject.toml index cb9c97849d..cbe69d4634 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -81,16 +81,17 @@ agent-framework = { workspace = true } agent-framework-core = { workspace = true } agent-framework-a2a = { workspace = true } agent-framework-ag-ui = { workspace = true } +agent-framework-anthropic = { workspace = true } agent-framework-azure-ai = { workspace = true } agent-framework-azurefunctions = { workspace = true } agent-framework-chatkit = { workspace = true } agent-framework-copilotstudio = { workspace = true } +agent-framework-declarative = { workspace = true } +agent-framework-devui = { workspace = true } agent-framework-lab = { workspace = true } agent-framework-mem0 = { workspace = true } -agent-framework-redis = { workspace = true } -agent-framework-devui = { workspace = true } agent-framework-purview = { workspace = true } -agent-framework-anthropic = { workspace = true } +agent-framework-redis = { workspace = true } [tool.ruff] line-length = 120 diff --git a/python/samples/getting_started/declarative/simple_agent.py b/python/samples/getting_started/declarative/simple_agent.py new file mode 100644 index 0000000000..8f0e9c46e0 --- /dev/null +++ b/python/samples/getting_started/declarative/simple_agent.py @@ -0,0 +1,19 @@ +# Copyright (c) Microsoft. All rights reserved. +from pathlib import Path + +from agent_framework_declarative import load_maml + + +def main(): + """Create an agent from a declarative yaml specification and run it.""" + current_path = Path(__file__).parent + yaml_path = current_path.parent.parent.parent.parent / "agent-samples" / "chatclient" / "Assistant.yaml" + + with yaml_path.open("r") as f: + yaml_str = f.read() + agent = load_maml(yaml_str) + print(agent) + + +if __name__ == "__main__": + main() diff --git a/python/uv.lock b/python/uv.lock index a073dc1a5d..e64ab5cb68 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -32,6 +32,7 @@ members = [ "agent-framework-chatkit", "agent-framework-copilotstudio", "agent-framework-core", + "agent-framework-declarative", "agent-framework-devui", "agent-framework-lab", "agent-framework-mem0", @@ -300,6 +301,7 @@ all = [ { name = "agent-framework-azurefunctions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-chatkit", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-copilotstudio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agent-framework-declarative", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-devui", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-lab", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "agent-framework-mem0", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -316,6 +318,7 @@ requires-dist = [ { name = "agent-framework-azurefunctions", marker = "extra == 'all'", editable = "packages/azurefunctions" }, { name = "agent-framework-chatkit", marker = "extra == 'all'", editable = "packages/chatkit" }, { name = "agent-framework-copilotstudio", marker = "extra == 'all'", editable = "packages/copilotstudio" }, + { name = "agent-framework-declarative", marker = "extra == 'all'", editable = "packages/declarative" }, { name = "agent-framework-devui", marker = "extra == 'all'", editable = "packages/devui" }, { name = "agent-framework-lab", marker = "extra == 'all'", editable = "packages/lab" }, { name = "agent-framework-mem0", marker = "extra == 'all'", editable = "packages/mem0" }, @@ -335,6 +338,21 @@ requires-dist = [ ] provides-extras = ["all"] +[[package]] +name = "agent-framework-declarative" +version = "1.0.0b251028" +source = { editable = "packages/declarative" } +dependencies = [ + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "agent-framework-core", editable = "packages/core" }, + { name = "pyyaml", specifier = ">=6.0,<7.0" }, +] + [[package]] name = "agent-framework-devui" version = "1.0.0b251114" From 26a3ae4ca0fad62b4b605abc2321d847eecc81ec Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Fri, 7 Nov 2025 15:47:46 +0100 Subject: [PATCH 02/17] initial version of the declarative support --- agent-samples/azure/AzureOpenAI.yaml | 6 +- .../azure/AzureOpenAIAssistants.yaml | 6 +- agent-samples/azure/AzureOpenAIResponses.yaml | 13 +- agent-samples/chatclient/Assistant.yaml | 4 +- agent-samples/chatclient/GetWeather.yaml | 12 +- .../foundry/MicrosoftLearnAgent.yaml | 7 +- agent-samples/foundry/PersistentAgent.yaml | 6 +- agent-samples/openai/OpenAI.yaml | 10 +- agent-samples/openai/OpenAIAssistants.yaml | 8 +- agent-samples/openai/OpenAIResponses.yaml | 12 +- python/.cspell.json | 2 + .../packages/core/agent_framework/_agents.py | 3 +- .../packages/core/agent_framework/_clients.py | 9 + .../agent_framework/declarative/__init__.py | 23 + .../agent_framework/declarative/__init__.pyi | 5 + .../agent_framework_declarative/__init__.py | 11 +- .../agent_framework_declarative/_loader.py | 382 +++++++++++++++- .../agent_framework_declarative/_models.py | 431 +++++++++++------- python/packages/declarative/pyproject.toml | 1 + ...t_loader.py => test_declarative_loader.py} | 38 +- ...t_models.py => test_declarative_models.py} | 273 +++++++++++ .../azure_openai_responses_agent.py | 27 ++ .../declarative/get_weather_agent.py | 40 ++ .../declarative/microsoft_learn_agent.py | 25 + .../declarative/openai_responses_agent.py | 26 ++ .../declarative/simple_agent.py | 19 - python/uv.lock | 26 ++ 27 files changed, 1201 insertions(+), 224 deletions(-) create mode 100644 python/packages/core/agent_framework/declarative/__init__.py create mode 100644 python/packages/core/agent_framework/declarative/__init__.pyi rename python/packages/declarative/tests/{test_loader.py => test_declarative_loader.py} (86%) rename python/packages/declarative/tests/{test_models.py => test_declarative_models.py} (68%) create mode 100644 python/samples/getting_started/declarative/azure_openai_responses_agent.py create mode 100644 python/samples/getting_started/declarative/get_weather_agent.py create mode 100644 python/samples/getting_started/declarative/microsoft_learn_agent.py create mode 100644 python/samples/getting_started/declarative/openai_responses_agent.py delete mode 100644 python/samples/getting_started/declarative/simple_agent.py diff --git a/agent-samples/azure/AzureOpenAI.yaml b/agent-samples/azure/AzureOpenAI.yaml index 4fd574bc1f..2f43d9ac92 100644 --- a/agent-samples/azure/AzureOpenAI.yaml +++ b/agent-samples/azure/AzureOpenAI.yaml @@ -12,14 +12,14 @@ model: outputSchema: properties: language: - type: string + kind: string required: true description: The language of the answer. answer: - type: string + kind: string required: true description: The answer text. type: - type: string + kind: string required: true description: The type of the response. diff --git a/agent-samples/azure/AzureOpenAIAssistants.yaml b/agent-samples/azure/AzureOpenAIAssistants.yaml index e3882695c2..8c0d889598 100644 --- a/agent-samples/azure/AzureOpenAIAssistants.yaml +++ b/agent-samples/azure/AzureOpenAIAssistants.yaml @@ -12,14 +12,14 @@ model: outputSchema: properties: language: - type: string + kind: string required: true description: The language of the answer. answer: - type: string + kind: string required: true description: The answer text. type: - type: string + kind: string required: true description: The type of the response. diff --git a/agent-samples/azure/AzureOpenAIResponses.yaml b/agent-samples/azure/AzureOpenAIResponses.yaml index fc70ac82eb..5db218ade3 100644 --- a/agent-samples/azure/AzureOpenAIResponses.yaml +++ b/agent-samples/azure/AzureOpenAIResponses.yaml @@ -7,19 +7,22 @@ model: provider: AzureOpenAI apiType: Responses options: - temperature: 0.9 - topP: 0.95 + text: + verbosity: medium + connection: + kind: remote + endpoint: =Env.AZURE_OPENAI_ENDPOINT outputSchema: properties: language: - type: string + kind: string required: true description: The language of the answer. answer: - type: string + kind: string required: true description: The answer text. type: - type: string + kind: string required: true description: The type of the response. diff --git a/agent-samples/chatclient/Assistant.yaml b/agent-samples/chatclient/Assistant.yaml index 81599d4e6f..b34add2d23 100644 --- a/agent-samples/chatclient/Assistant.yaml +++ b/agent-samples/chatclient/Assistant.yaml @@ -9,10 +9,10 @@ model: outputSchema: properties: language: - type: string + kind: string required: true description: The language of the answer. answer: - type: string + kind: string required: true description: The answer text. diff --git a/agent-samples/chatclient/GetWeather.yaml b/agent-samples/chatclient/GetWeather.yaml index 798d2e4245..a2b203b813 100644 --- a/agent-samples/chatclient/GetWeather.yaml +++ b/agent-samples/chatclient/GetWeather.yaml @@ -4,21 +4,21 @@ description: Helpful assistant instructions: You are a helpful assistant. You answer questions using the tools provided. model: options: - temperature: 0.9 - topP: 0.95 allowMultipleToolCalls: true chatToolMode: auto tools: - kind: function name: GetWeather description: Get the weather for a given location. + bindings: + get_weather: get_weather parameters: - - name: location - type: string + location: + kind: string description: The city and state, e.g. San Francisco, CA required: true - - name: unit - type: string + unit: + kind: string description: The unit of temperature. Possible values are 'celsius' and 'fahrenheit'. required: false enum: diff --git a/agent-samples/foundry/MicrosoftLearnAgent.yaml b/agent-samples/foundry/MicrosoftLearnAgent.yaml index 817aeadc4e..8e15340351 100644 --- a/agent-samples/foundry/MicrosoftLearnAgent.yaml +++ b/agent-samples/foundry/MicrosoftLearnAgent.yaml @@ -8,13 +8,14 @@ model: temperature: 0.9 topP: 0.95 connection: - kind: ExternalReference + kind: remote endpoint: =Env.AZURE_FOUNDRY_PROJECT_ENDPOINT tools: - kind: mcp name: microsoft_learn description: Get information from Microsoft Learn. url: https://learn.microsoft.com/api/mcp - requireApproval: requireSpecific + approvalMode: + kind: never allowedTools: - - microsoft_docs_search \ No newline at end of file + - microsoft_docs_search diff --git a/agent-samples/foundry/PersistentAgent.yaml b/agent-samples/foundry/PersistentAgent.yaml index 774c51d864..298ded2202 100644 --- a/agent-samples/foundry/PersistentAgent.yaml +++ b/agent-samples/foundry/PersistentAgent.yaml @@ -8,15 +8,15 @@ model: temperature: 0.9 topP: 0.95 connection: - kind: ExternalReference + kind: remote endpoint: =Env.AZURE_FOUNDRY_PROJECT_ENDPOINT outputSchema: properties: language: - type: string + kind: string required: true description: The language of the answer. answer: - type: string + kind: string required: true description: The answer text. diff --git a/agent-samples/openai/OpenAI.yaml b/agent-samples/openai/OpenAI.yaml index 4a9994c2ff..0e70188fd6 100644 --- a/agent-samples/openai/OpenAI.yaml +++ b/agent-samples/openai/OpenAI.yaml @@ -10,19 +10,19 @@ model: temperature: 0.9 topP: 0.95 connection: - kind: ApiKey - key: =Env.OPENAI_APIKEY + kind: key + key: =Env.OPENAI_API_KEY outputSchema: properties: language: - type: string + kind: string required: true description: The language of the answer. answer: - type: string + kind: string required: true description: The answer text. type: - type: string + kind: string required: true description: The type of the response. diff --git a/agent-samples/openai/OpenAIAssistants.yaml b/agent-samples/openai/OpenAIAssistants.yaml index c5cbb4ccd1..78bd48d701 100644 --- a/agent-samples/openai/OpenAIAssistants.yaml +++ b/agent-samples/openai/OpenAIAssistants.yaml @@ -10,21 +10,21 @@ model: temperature: 0.9 topP: 0.95 connection: - kind: ApiKey + kind: key key: =Env.OPENAI_APIKEY outputSchema: name: AssistantResponse description: The response from the assistant. properties: language: - type: string + kind: string required: true description: The language of the answer. answer: - type: string + kind: string required: true description: The answer text. type: - type: string + kind: string required: true description: The type of the response. diff --git a/agent-samples/openai/OpenAIResponses.yaml b/agent-samples/openai/OpenAIResponses.yaml index 105ade1cf6..0fcda30c9c 100644 --- a/agent-samples/openai/OpenAIResponses.yaml +++ b/agent-samples/openai/OpenAIResponses.yaml @@ -7,22 +7,22 @@ model: provider: OpenAI apiType: Responses options: - temperature: 0.9 - topP: 0.95 + text: + verbosity: medium connection: - kind: ApiKey + kind: key key: =Env.OPENAI_APIKEY outputSchema: properties: language: - type: string + kind: string required: true description: The language of the answer. answer: - type: string + kind: string required: true description: The answer text. type: - type: string + kind: string required: true description: The type of the response. diff --git a/python/.cspell.json b/python/.cspell.json index 1b21f5263d..76332bdb12 100644 --- a/python/.cspell.json +++ b/python/.cspell.json @@ -45,6 +45,7 @@ "logit", "logprobs", "lowlevel", + "maml", "Magentic", "mistralai", "mongocluster", @@ -59,6 +60,7 @@ "OPENAI", "opentelemetry", "OTEL", + "powerfx", "protos", "pydantic", "pytestmark", diff --git a/python/packages/core/agent_framework/_agents.py b/python/packages/core/agent_framework/_agents.py index e3ea1bdea6..374ed8db3a 100644 --- a/python/packages/core/agent_framework/_agents.py +++ b/python/packages/core/agent_framework/_agents.py @@ -589,7 +589,7 @@ def __init__( 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, @@ -850,6 +850,7 @@ async def run( co = run_chat_options & ChatOptions( model_id=model_id, + allow_multiple_tool_calls=allow_multiple_tool_calls, conversation_id=thread.service_thread_id, allow_multiple_tool_calls=allow_multiple_tool_calls, frequency_penalty=frequency_penalty, diff --git a/python/packages/core/agent_framework/_clients.py b/python/packages/core/agent_framework/_clients.py index 630e7f8709..40c13a2037 100644 --- a/python/packages/core/agent_framework/_clients.py +++ b/python/packages/core/agent_framework/_clients.py @@ -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 @@ async def get_response( 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 @@ async def get_response( 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 @@ async def get_response( 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 @@ async def get_streaming_response( 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 @@ async def get_streaming_response( 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 @@ async def get_streaming_response( 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, diff --git a/python/packages/core/agent_framework/declarative/__init__.py b/python/packages/core/agent_framework/declarative/__init__.py new file mode 100644 index 0000000000..8486cdee6c --- /dev/null +++ b/python/packages/core/agent_framework/declarative/__init__.py @@ -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"] + + +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 diff --git a/python/packages/core/agent_framework/declarative/__init__.pyi b/python/packages/core/agent_framework/declarative/__init__.pyi new file mode 100644 index 0000000000..ea27e02760 --- /dev/null +++ b/python/packages/core/agent_framework/declarative/__init__.pyi @@ -0,0 +1,5 @@ +# Copyright (c) Microsoft. All rights reserved. + +from agent_framework_declarative import AgentFactory, DeclarativeLoaderError, ProviderLookupError, __version__ + +__all__ = ["AgentFactory", "DeclarativeLoaderError", "ProviderLookupError", "__version__"] diff --git a/python/packages/declarative/agent_framework_declarative/__init__.py b/python/packages/declarative/agent_framework_declarative/__init__.py index 62cb297219..6f975ef9c2 100644 --- a/python/packages/declarative/agent_framework_declarative/__init__.py +++ b/python/packages/declarative/agent_framework_declarative/__init__.py @@ -1,5 +1,12 @@ # Copyright (c) Microsoft. All rights reserved. -from ._loader import load_maml +import importlib -__all__ = ["load_maml"] +from ._loader import AgentFactory, DeclarativeLoaderError, ProviderLookupError + +try: + __version__ = importlib.metadata.version(__name__) +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" # Fallback for development mode + +__all__ = ["AgentFactory", "DeclarativeLoaderError", "ProviderLookupError", "__version__"] diff --git a/python/packages/declarative/agent_framework_declarative/_loader.py b/python/packages/declarative/agent_framework_declarative/_loader.py index 4cd7f6c182..27a96b5e65 100644 --- a/python/packages/declarative/agent_framework_declarative/_loader.py +++ b/python/packages/declarative/agent_framework_declarative/_loader.py @@ -1,8 +1,26 @@ # Copyright (c) Microsoft. All rights reserved. -from typing import Any +from collections.abc import Callable, Mapping +from pathlib import Path +from typing import Any, 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 ( AgentDefinition, @@ -42,6 +60,68 @@ ) +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.Completions": { + "package": "agent_framework.openai", + "name": "OpenAICompletionsClient", + "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", + }, + "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 + + def load_maml(yaml_str: str) -> Any: """Load a MAML object from a YAML string. @@ -148,3 +228,303 @@ def load_maml(yaml_str: str) -> Any: # Unknown kind case _: return None + + +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, + 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 constructors. + env_file: An optional path to a .env file to load environment variables from. + 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", + }, + ... + } + """ + self.chat_client = chat_client + self.bindings = bindings + self.connections = connections + self.client_kwargs = client_kwargs or {} + self.additional_mappings = additional_mappings or {} + load_dotenv(dotenv_path=env_file) + + def create_agent_from_yaml_path(self, yaml_path: str | Path) -> ChatAgent: + """Create a MAML object from a YAML file path asynchronously. + + This method wraps the synchronous load_maml function to provide + asynchronous behavior. + + Args: + yaml_path: Path to the YAML file representation of a MAML object + Returns: + The object 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. + 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 MAML object from a YAML string asynchronously. + + This method wraps the synchronous load_maml function to provide + asynchronous behavior. + + This method does the following things: + 1. Loads the YAML string into a MAML object using load_maml. + 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 MAML object + + Returns: + The object 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. + 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 = load_maml(yaml_str) + if not isinstance(prompt_agent, PromptAgent): + raise DeclarativeLoaderError("Only PromptAgent kind is supported for agent creation") + + # Step 1: Create the ChatClient + setup_dict: dict[str, Any] = {} + setup_dict.update(self.client_kwargs) + # resolve connections: + client: ChatClientProtocol | None = None + if prompt_agent.model.connection: + if prompt_agent.model.connection.kind == "key": + setup_dict["api_key"] = prompt_agent.model.connection.apiKey + if prompt_agent.model.connection.endpoint: + setup_dict["endpoint"] = prompt_agent.model.connection.endpoint + elif prompt_agent.model.connection.kind == "remote": + setup_dict["endpoint"] = prompt_agent.model.connection.endpoint + elif prompt_agent.model.connection.kind == "reference": + # find the referenced connection + if not self.connections: + raise ValueError("Connections must be provided to resolve ReferenceConnection") + for name, value in self.connections.items(): + if name == prompt_agent.model.connection.name: + setup_dict[name] = value + break + else: + raise ValueError(f"Referenced connection '{prompt_agent.model.connection.referenceName}' not found") + elif prompt_agent.model.connection.kind == "Anonymous": + setup_dict["endpoint"] = prompt_agent.model.connection.endpoint + # check if there is a model.provider and model.apiType defined + if prompt_agent.model.provider and prompt_agent.model.apiType: + # lookup the provider type in the mapping + class_lookup = f"{prompt_agent.model.provider}.{prompt_agent.model.apiType}" + if class_lookup in PROVIDER_TYPE_OBJECT_MAPPING: + mapping = self._retrieve_provider_configuration(class_lookup) + 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 + client = agent_class(**setup_dict) + else: + raise ValueError("Unsupported model provider or apiType in PromptAgent") + if not client and prompt_agent.model.id: + # assume AzureAIAgentClient + mapping = self._retrieve_provider_configuration("AzureAIAgentClient") + 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 + client = agent_class(**setup_dict) + elif not client: + # get a ChatClientProtocol supplied + if not self.chat_client: + raise ValueError("ChatClient must be provided to create agent from PromptAgent") + client = self.chat_client + # Step 2: Parse the other properties, including tools, options and response_format + # Options + chat_options: dict[str, Any] = {} + if prompt_agent.model and (options := prompt_agent.model.options) and isinstance(options, ModelOptions): + 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 + # Tools + tools: list[ToolProtocol] = [] + if prompt_agent.tools: + for tool_resource in prompt_agent.tools: + 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 in self.bindings: + func = self.bindings[binding.name] + break + json_schema = tool_resource.parameters.to_dict(exclude={"type"}, exclude_none=True) + new_props = {} + for prop in json_schema.get("properties", []): + prop_name = prop.pop("name") + prop["type"] = prop.pop("kind", None) + new_props[prop_name] = prop + json_schema["properties"] = new_props + tools.append( + AIFunction( # type: ignore + name=tool_resource.name, + description=tool_resource.description, + input_model=json_schema, + func=func, + ) + ) + case WebSearchTool(): + tools.append( + 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 + tools.append( + 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(): + tools.append( + HostedCodeInterpreterTool( + inputs=[HostedFileContent(file_id=file) for file in tool_resource.fileIds or []], + description=tool_resource.description, + ) + ) + case McpTool(): + approval_mode: HostedMCPSpecificApproval | str | None = 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): + if tool_resource.approvalMode.alwaysRequireApprovalTools: + approval_mode = { + "always_require_approval": tool_resource.approvalMode.alwaysRequireApprovalTools + } + else: + approval_mode = { + "never_require_approval": tool_resource.approvalMode.neverRequireApprovalTools + } + tools.append( + HostedMCPTool( + name=tool_resource.name, + description=tool_resource.description, + url=tool_resource.url, + allowed_tools=tool_resource.allowedTools, + approval_mode=approval_mode, + ) + ) + case _: + raise ValueError(f"Unsupported tool kind: {tool_resource.kind}") + + # response format + if prompt_agent.outputSchema: + json_schema = prompt_agent.outputSchema.to_dict(exclude={"type"}, exclude_none=True) + new_props = {} + for prop in json_schema.get("properties", []): + prop_name = prop.pop("name") + prop["type"] = prop.pop("kind", None) + new_props[prop_name] = prop + json_schema["properties"] = new_props + pydantic_model = _create_model_from_json_schema("agent", json_schema) + chat_options["response_format"] = pydantic_model + + # Step 3: Create the agent instance + return ChatAgent( + chat_client=client, + name=prompt_agent.name, + description=prompt_agent.description, + instructions=prompt_agent.instructions, + tools=tools, + **chat_options, + ) + + def _retrieve_provider_configuration(self, class_lookup: str) -> ProviderTypeMapping: + """Retrieve the provider configuration for a given class lookup. + + This method will first attempt to find the class lookup in the additional mappings + provided to the AgentFactory. If not found there, it will look in the default + PROVIDER_TYPE_OBJECT_MAPPING. + + Args: + class_lookup: The class lookup string in the format 'Provider.ApiType' + + Returns: + A dictionary containing the package, name, and model_id_field for the provider. + + Raises: + ProviderLookupError: If the provider type is not supported. + """ + 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] diff --git a/python/packages/declarative/agent_framework_declarative/_models.py b/python/packages/declarative/agent_framework_declarative/_models.py index a72000e6b4..ef9c71d636 100644 --- a/python/packages/declarative/agent_framework_declarative/_models.py +++ b/python/packages/declarative/agent_framework_declarative/_models.py @@ -1,9 +1,31 @@ # Copyright (c) Microsoft. All rights reserved. - +import os from collections.abc import MutableMapping -from typing import Any +from typing import Any, TypeVar +from agent_framework import get_logger from agent_framework._serialization import SerializationMixin +from powerfx import Engine + +engine = Engine() + +logger = get_logger("agent_framework.declarative") + + +def _try_powerfx_eval(value: str | None) -> str | None: + """Check if a value refers to a environment variable and parse it if so.""" + if not value or not value.startswith("="): + return value + try: + env = dict(os.environ) + except Exception as exc: + logger.info("Failed to get environment variables for PowerFx evaluation: %s", exc) + env = {} + try: + return engine.eval(value[1:], symbols={"Env": env}) + except Exception as exc: + logger.info("PowerFx evaluation failed for value '%s': %s", value, exc) + return None class Binding(SerializationMixin): @@ -11,11 +33,11 @@ class Binding(SerializationMixin): def __init__( self, - name: str = "", - input: str = "", + name: str | None = None, + input: str | None = None, ) -> None: - self.name = name - self.input = input + self.name = _try_powerfx_eval(name) + self.input = _try_powerfx_eval(input) class Property(SerializationMixin): @@ -23,21 +45,21 @@ class Property(SerializationMixin): def __init__( self, - name: str = "", - kind: str = "", + name: str | None = None, + kind: str | None = None, description: str | None = None, required: bool | None = None, default: Any | None = None, example: Any | None = None, - enumValues: list[Any] | None = None, + enum: list[Any] | None = None, ) -> None: - self.name = name - self.kind = kind - self.description = description + self.name = _try_powerfx_eval(name) + self.kind = _try_powerfx_eval(kind) + self.description = _try_powerfx_eval(description) self.required = required self.default = default self.example = example - self.enumValues = enumValues or [] + self.enum = enum or [] @classmethod def from_dict( @@ -49,6 +71,11 @@ def from_dict( # We're being called on a subclass, use the normal from_dict return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[misc] + # Filter out 'type' field which is not a Property parameter + if "type" in value: + value = dict(value) if "enum" not in value else value # Only copy if not already copied + value.pop("type", None) + kind = value.get("kind", "") if kind == "array": from agent_framework_declarative._models import ArrayProperty @@ -67,13 +94,13 @@ class ArrayProperty(Property): def __init__( self, - name: str = "", + name: str | None = None, kind: str = "array", description: str | None = None, required: bool | None = None, default: Any | None = None, example: Any | None = None, - enumValues: list[Any] | None = None, + enum: list[Any] | None = None, items: Property | None = None, ) -> None: super().__init__( @@ -83,7 +110,7 @@ def __init__( required=required, default=default, example=example, - enumValues=enumValues, + enum=enum, ) if not isinstance(items, Property) and items is not None: items = Property.from_dict(items) @@ -95,14 +122,14 @@ class ObjectProperty(Property): def __init__( self, - name: str = "", + name: str | None = None, kind: str = "object", description: str | None = None, required: bool | None = None, default: Any | None = None, example: Any | None = None, - enumValues: list[Any] | None = None, - properties: list[Property] | None = None, + enum: list[Any] | None = None, + properties: list[Property] | dict[str, Property] | None = None, ) -> None: super().__init__( name=name, @@ -111,13 +138,19 @@ def __init__( required=required, default=default, example=example, - enumValues=enumValues, + enum=enum, ) - converted_properties = [] - for prop in properties or []: - if not isinstance(prop, Property): - prop = Property.from_dict(prop) - converted_properties.append(prop) + converted_properties: list[Property] = [] + if isinstance(properties, list): + for prop in properties: + if not isinstance(prop, Property): + prop = Property.from_dict(prop) + converted_properties.append(prop) + elif isinstance(properties, dict): + for k, v in properties.items(): + temp_prop = {"name": k, **v} + prop = Property.from_dict(temp_prop) + converted_properties.append(prop) self.properties = converted_properties @@ -128,15 +161,21 @@ def __init__( self, examples: list[dict[str, Any]] | None = None, strict: bool = False, - properties: list[Property] | None = None, + properties: list[Property] | dict[str, Property] | None = None, ) -> None: self.examples = examples or [] self.strict = strict - converted_properties = [] - for prop in properties or []: - if not isinstance(prop, Property): - prop = Property.from_dict(prop) - converted_properties.append(prop) + converted_properties: list[Property] = [] + if isinstance(properties, list): + for prop in properties: + if not isinstance(prop, Property): + prop = Property.from_dict(prop) + converted_properties.append(prop) + elif isinstance(properties, dict): + for k, v in properties.items(): + temp_prop = {"name": k, **v} + prop = Property.from_dict(temp_prop) + converted_properties.append(prop) self.properties = converted_properties @classmethod @@ -144,8 +183,9 @@ def from_dict( cls, value: MutableMapping[str, Any], /, *, dependencies: MutableMapping[str, Any] | None = None ) -> "PropertySchema": """Create a PropertySchema instance from a dictionary, filtering out 'kind' field.""" - # Filter out 'kind' and 'type' fields - kwargs = {k: v for k, v in value.items() if k not in ("type", "kind")} + # Filter out 'kind', 'type', 'name', and 'description' fields that may appear in YAML + # but aren't PropertySchema params + kwargs = {k: v for k, v in value.items() if k not in ("type", "kind", "name", "description")} return SerializationMixin.from_dict.__func__(cls, kwargs, dependencies=dependencies) # type: ignore[misc] @@ -154,13 +194,13 @@ class Connection(SerializationMixin): def __init__( self, - kind: str = "", - authenticationMode: str = "", - usageDescription: str = "", + kind: str | None = None, + authenticationMode: str | None = None, + usageDescription: str | None = None, ) -> None: - self.kind = kind - self.authenticationMode = authenticationMode - self.usageDescription = usageDescription + self.kind = _try_powerfx_eval(kind) + self.authenticationMode = _try_powerfx_eval(authenticationMode) + self.usageDescription = _try_powerfx_eval(usageDescription) @classmethod def from_dict( @@ -198,18 +238,18 @@ class ReferenceConnection(Connection): def __init__( self, kind: str = "reference", - authenticationMode: str = "", - usageDescription: str = "", - name: str = "", - target: str = "", + authenticationMode: str | None = None, + usageDescription: str | None = None, + name: str | None = None, + target: str | None = None, ) -> None: super().__init__( kind=kind, authenticationMode=authenticationMode, usageDescription=usageDescription, ) - self.name = name - self.target = target + self.name = _try_powerfx_eval(name) + self.target = _try_powerfx_eval(target) class RemoteConnection(Connection): @@ -218,18 +258,18 @@ class RemoteConnection(Connection): def __init__( self, kind: str = "remote", - authenticationMode: str = "", - usageDescription: str = "", - name: str = "", - endpoint: str = "", + authenticationMode: str | None = None, + usageDescription: str | None = None, + name: str | None = None, + endpoint: str | None = None, ) -> None: super().__init__( kind=kind, authenticationMode=authenticationMode, usageDescription=usageDescription, ) - self.name = name - self.endpoint = endpoint + self.name = _try_powerfx_eval(name) + self.endpoint = _try_powerfx_eval(endpoint) class ApiKeyConnection(Connection): @@ -238,18 +278,20 @@ class ApiKeyConnection(Connection): def __init__( self, kind: str = "key", - authenticationMode: str = "", - usageDescription: str = "", - endpoint: str = "", - apiKey: str = "", + authenticationMode: str | None = None, + usageDescription: str | None = None, + endpoint: str | None = None, + apiKey: str | None = None, + key: str | None = None, ) -> None: super().__init__( kind=kind, authenticationMode=authenticationMode, usageDescription=usageDescription, ) - self.endpoint = endpoint - self.apiKey = apiKey + self.endpoint = _try_powerfx_eval(endpoint) + # Support both 'apiKey' and 'key' fields, with 'key' taking precedence if both are provided + self.apiKey = _try_powerfx_eval(key if key else apiKey) class AnonymousConnection(Connection): @@ -258,16 +300,16 @@ class AnonymousConnection(Connection): def __init__( self, kind: str = "anonymous", - authenticationMode: str = "", - usageDescription: str = "", - endpoint: str = "", + authenticationMode: str | None = None, + usageDescription: str | None = None, + endpoint: str | None = None, ) -> None: super().__init__( kind=kind, authenticationMode=authenticationMode, usageDescription=usageDescription, ) - self.endpoint = endpoint + self.endpoint = _try_powerfx_eval(endpoint) class ModelOptions(SerializationMixin): @@ -285,6 +327,7 @@ def __init__( stopSequences: list[str] | None = None, allowMultipleToolCalls: bool | None = None, additionalProperties: dict[str, Any] | None = None, + **kwargs: Any, ) -> None: self.frequencyPenalty = frequencyPenalty self.maxOutputTokens = maxOutputTokens @@ -295,7 +338,9 @@ def __init__( self.topP = topP self.stopSequences = stopSequences or [] self.allowMultipleToolCalls = allowMultipleToolCalls + # Merge any additional properties from kwargs into additionalProperties self.additionalProperties = additionalProperties or {} + self.additionalProperties.update(kwargs) class Model(SerializationMixin): @@ -303,15 +348,15 @@ class Model(SerializationMixin): def __init__( self, - id: str = "", - provider: str = "", - apiType: str = "", + id: str | None = None, + provider: str | None = None, + apiType: str | None = None, connection: Connection | None = None, options: ModelOptions | None = None, ) -> None: - self.id = id - self.provider = provider - self.apiType = apiType + self.id = _try_powerfx_eval(id) + self.provider = _try_powerfx_eval(provider) + self.apiType = _try_powerfx_eval(apiType) if not isinstance(connection, Connection) and connection is not None: connection = Connection.from_dict(connection) self.connection = connection @@ -325,11 +370,11 @@ class Format(SerializationMixin): def __init__( self, - kind: str = "", + kind: str | None = None, strict: bool = False, options: dict[str, Any] | None = None, ) -> None: - self.kind = kind + self.kind = _try_powerfx_eval(kind) self.strict = strict self.options = options or {} @@ -339,10 +384,10 @@ class Parser(SerializationMixin): def __init__( self, - kind: str = "", + kind: str | None = None, options: dict[str, Any] | None = None, ) -> None: - self.kind = kind + self.kind = _try_powerfx_eval(kind) self.options = options or {} @@ -367,18 +412,18 @@ class AgentDefinition(SerializationMixin): def __init__( self, - kind: str = "", - name: str = "", - displayName: str = "", - description: str = "", + kind: str | None = None, + name: str | None = None, + displayName: str | None = None, + description: str | None = None, metadata: dict[str, Any] | None = None, inputSchema: PropertySchema | None = None, outputSchema: PropertySchema | None = None, ) -> None: - self.kind = kind - self.name = name - self.displayName = displayName - self.description = description + self.kind = _try_powerfx_eval(kind) + self.name = _try_powerfx_eval(name) + self.displayName = _try_powerfx_eval(displayName) + self.description = _try_powerfx_eval(description) self.metadata = metadata if not isinstance(inputSchema, PropertySchema) and inputSchema is not None: inputSchema = PropertySchema.from_dict(inputSchema) @@ -406,37 +451,88 @@ def from_dict( return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[misc] +TTool = TypeVar("TTool", bound="Tool") + + class Tool(SerializationMixin): """Base class for tools.""" def __init__( self, - name: str = "", - kind: str = "", - description: str = "", - bindings: list[Binding] | None = None, - ) -> None: - self.name = name - self.kind = kind - self.description = description - converted_bindings = [] - for binding in bindings or []: - if not isinstance(binding, Binding): - binding = Binding.from_dict(binding) - converted_bindings.append(binding) + name: str | None = None, + kind: str | None = None, + description: str | None = None, + bindings: list[Binding] | dict[str, Any] | None = None, + ) -> None: + self.name = _try_powerfx_eval(name) + self.kind = _try_powerfx_eval(kind) + self.description = _try_powerfx_eval(description) + converted_bindings: list[Binding] = [] + if isinstance(bindings, list): + for binding in bindings: + if not isinstance(binding, Binding): + binding = Binding.from_dict(binding) + converted_bindings.append(binding) + elif isinstance(bindings, dict): + for k, v in bindings.items(): + temp_binding = {"name": k, "input": v} if isinstance(v, str) else {"name": k, **v} + binding = Binding.from_dict(temp_binding) + converted_bindings.append(binding) self.bindings = converted_bindings + @classmethod + def from_dict( + cls: type[TTool], value: MutableMapping[str, Any], /, *, dependencies: MutableMapping[str, Any] | None = None + ) -> "TTool": + """Create a Tool instance from a dictionary, dispatching to the appropriate subclass.""" + # Only dispatch if we're being called on the base Tool class + if cls is not Tool: + # We're being called on a subclass, use the normal from_dict + return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[misc] + + kind = value.get("kind", "") + if kind == "function": + return SerializationMixin.from_dict.__func__( # type: ignore[misc] + FunctionTool, value, dependencies=dependencies + ) + if kind == "custom": + return SerializationMixin.from_dict.__func__( # type: ignore[misc] + CustomTool, value, dependencies=dependencies + ) + if kind == "web_search": + return SerializationMixin.from_dict.__func__( # type: ignore[misc] + WebSearchTool, value, dependencies=dependencies + ) + if kind == "file_search": + return SerializationMixin.from_dict.__func__( # type: ignore[misc] + FileSearchTool, value, dependencies=dependencies + ) + if kind == "mcp": + return SerializationMixin.from_dict.__func__( # type: ignore[misc] + McpTool, value, dependencies=dependencies + ) + if kind == "openapi": + return SerializationMixin.from_dict.__func__( # type: ignore[misc] + OpenApiTool, value, dependencies=dependencies + ) + if kind == "code_interpreter": + return SerializationMixin.from_dict.__func__( # type: ignore[misc] + CodeInterpreterTool, value, dependencies=dependencies + ) + # Default to base Tool class + return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[misc] + class FunctionTool(Tool): """Object representing a function tool.""" def __init__( self, - name: str = "", + name: str | None = None, kind: str = "function", - description: str = "", + description: str | None = None, bindings: list[Binding] | None = None, - parameters: PropertySchema | None = None, + parameters: PropertySchema | list[Property] | None = None, strict: bool = False, ) -> None: super().__init__( @@ -445,7 +541,10 @@ def __init__( description=description, bindings=bindings, ) - if not isinstance(parameters, PropertySchema) and parameters is not None: + if isinstance(parameters, (list, dict)): + # If parameters is a list, wrap it in a PropertySchema + parameters = PropertySchema(properties=parameters) + elif not isinstance(parameters, PropertySchema) and parameters is not None: parameters = PropertySchema.from_dict(parameters) self.parameters = parameters self.strict = strict @@ -456,9 +555,9 @@ class CustomTool(Tool): def __init__( self, - name: str = "", + name: str | None = None, kind: str = "custom", - description: str = "", + description: str | None = None, bindings: list[Binding] | None = None, connection: Connection | None = None, options: dict[str, Any] | None = None, @@ -480,9 +579,9 @@ class WebSearchTool(Tool): def __init__( self, - name: str = "", + name: str | None = None, kind: str = "web_search", - description: str = "", + description: str | None = None, bindings: list[Binding] | None = None, connection: Connection | None = None, options: dict[str, Any] | None = None, @@ -504,9 +603,9 @@ class FileSearchTool(Tool): def __init__( self, - name: str = "", + name: str | None = None, kind: str = "file_search", - description: str = "", + description: str | None = None, bindings: list[Binding] | None = None, connection: Connection | None = None, vectorStoreIds: list[str] | None = None, @@ -526,7 +625,7 @@ def __init__( self.connection = connection self.vectorStoreIds = vectorStoreIds or [] self.maximumResultCount = maximumResultCount - self.ranker = ranker + self.ranker = _try_powerfx_eval(ranker) self.scoreThreshold = scoreThreshold self.filters = filters or {} @@ -536,9 +635,9 @@ class McpServerApprovalMode(SerializationMixin): def __init__( self, - kind: str = "", + kind: str | None = None, ) -> None: - self.kind = kind + self.kind = _try_powerfx_eval(kind) class McpServerToolAlwaysRequireApprovalMode(McpServerApprovalMode): @@ -571,8 +670,8 @@ def __init__( neverRequireApprovalTools: list[str] | None = None, ) -> None: super().__init__(kind=kind) - self.alwaysRequireApprovalTools = alwaysRequireApprovalTools or [] - self.neverRequireApprovalTools = neverRequireApprovalTools or [] + self.alwaysRequireApprovalTools = alwaysRequireApprovalTools + self.neverRequireApprovalTools = neverRequireApprovalTools class McpTool(Tool): @@ -580,15 +679,16 @@ class McpTool(Tool): def __init__( self, - name: str = "", + name: str | None = None, kind: str = "mcp", - description: str = "", + description: str | None = None, bindings: list[Binding] | None = None, connection: Connection | None = None, - serverName: str = "", - serverDescription: str = "", + serverName: str | None = None, + serverDescription: str | None = None, approvalMode: McpServerApprovalMode | None = None, allowedTools: list[str] | None = None, + url: str | None = None, ) -> None: super().__init__( name=name, @@ -599,12 +699,17 @@ def __init__( if not isinstance(connection, Connection) and connection is not None: connection = Connection.from_dict(connection) self.connection = connection - self.serverName = serverName - self.serverDescription = serverDescription + self.serverName = _try_powerfx_eval(serverName) + self.serverDescription = _try_powerfx_eval(serverDescription) if not isinstance(approvalMode, McpServerApprovalMode) and approvalMode is not None: - approvalMode = McpServerApprovalMode.from_dict(approvalMode) + # Handle simplified string format: "always" -> {"kind": "always"} + if isinstance(approvalMode, str): + approvalMode = McpServerApprovalMode.from_dict({"kind": approvalMode}) + else: + approvalMode = McpServerApprovalMode.from_dict(approvalMode) self.approvalMode = approvalMode self.allowedTools = allowedTools or [] + self.url = _try_powerfx_eval(url) class OpenApiTool(Tool): @@ -612,12 +717,12 @@ class OpenApiTool(Tool): def __init__( self, - name: str = "", + name: str | None = None, kind: str = "openapi", - description: str = "", + description: str | None = None, bindings: list[Binding] | None = None, connection: Connection | None = None, - specification: str = "", + specification: str | None = None, ) -> None: super().__init__( name=name, @@ -628,7 +733,7 @@ def __init__( if not isinstance(connection, Connection) and connection is not None: connection = Connection.from_dict(connection) self.connection = connection - self.specification = specification + self.specification = _try_powerfx_eval(specification) class CodeInterpreterTool(Tool): @@ -636,9 +741,9 @@ class CodeInterpreterTool(Tool): def __init__( self, - name: str = "", + name: str | None = None, kind: str = "code_interpreter", - description: str = "", + description: str | None = None, bindings: list[Binding] | None = None, fileIds: list[str] | None = None, ) -> None: @@ -657,17 +762,17 @@ class PromptAgent(AgentDefinition): def __init__( self, kind: str = "Prompt", - name: str = "", - displayName: str = "", - description: str = "", + name: str | None = None, + displayName: str | None = None, + description: str | None = None, metadata: dict[str, Any] | None = None, inputSchema: PropertySchema | None = None, outputSchema: PropertySchema | None = None, - model: Model | None = None, + model: Model | dict[str, Any] | None = None, tools: list[Tool] | None = None, - template: Template | None = None, - instructions: str = "", - additionalInstructions: str = "", + template: Template | dict[str, Any] | None = None, + instructions: str | None = None, + additionalInstructions: str | None = None, ) -> None: super().__init__( kind=kind, @@ -681,7 +786,7 @@ def __init__( if not isinstance(model, Model) and model is not None: model = Model.from_dict(model) self.model = model - converted_tools = [] + converted_tools: list[Tool] = [] for tool in tools or []: if not isinstance(tool, Tool): tool = Tool.from_dict(tool) @@ -690,8 +795,8 @@ def __init__( if not isinstance(template, Template) and template is not None: template = Template.from_dict(template) self.template = template - self.instructions = instructions - self.additionalInstructions = additionalInstructions + self.instructions = _try_powerfx_eval(instructions) + self.additionalInstructions = _try_powerfx_eval(additionalInstructions) class Resource(SerializationMixin): @@ -699,11 +804,11 @@ class Resource(SerializationMixin): def __init__( self, - name: str = "", - kind: str = "", + name: str | None = None, + kind: str | None = None, ) -> None: - self.name = name - self.kind = kind + self.name = _try_powerfx_eval(name) + self.kind = _try_powerfx_eval(kind) @classmethod def from_dict( @@ -733,11 +838,11 @@ class ModelResource(Resource): def __init__( self, kind: str = "model", - name: str = "", - id: str = "", + name: str | None = None, + id: str | None = None, ) -> None: super().__init__(kind=kind, name=name) - self.id = id + self.id = _try_powerfx_eval(id) class ToolResource(Resource): @@ -746,12 +851,12 @@ class ToolResource(Resource): def __init__( self, kind: str = "tool", - name: str = "", - id: str = "", + name: str | None = None, + id: str | None = None, options: dict[str, Any] | None = None, ) -> None: super().__init__(kind=kind, name=name) - self.id = id + self.id = _try_powerfx_eval(id) self.options = options or {} @@ -760,11 +865,11 @@ class ProtocolVersionRecord(SerializationMixin): def __init__( self, - protocol: str = "", - version: str = "", + protocol: str | None = None, + version: str | None = None, ) -> None: - self.protocol = protocol - self.version = version + self.protocol = _try_powerfx_eval(protocol) + self.version = _try_powerfx_eval(version) class EnvironmentVariable(SerializationMixin): @@ -772,11 +877,11 @@ class EnvironmentVariable(SerializationMixin): def __init__( self, - name: str = "", - value: str = "", + name: str | None = None, + value: str | None = None, ) -> None: - self.name = name - self.value = value + self.name = _try_powerfx_eval(name) + self.value = _try_powerfx_eval(value) class AgentManifest(SerializationMixin): @@ -784,17 +889,17 @@ class AgentManifest(SerializationMixin): def __init__( self, - name: str = "", - displayName: str = "", - description: str = "", + name: str | None = None, + displayName: str | None = None, + description: str | None = None, metadata: dict[str, Any] | None = None, template: AgentDefinition | None = None, parameters: PropertySchema | None = None, - resources: list[Resource] | None = None, + resources: list[Resource] | dict[str, Any] | None = None, ) -> None: - self.name = name - self.displayName = displayName - self.description = description + self.name = _try_powerfx_eval(name) + self.displayName = _try_powerfx_eval(displayName) + self.description = _try_powerfx_eval(description) self.metadata = metadata or {} if not isinstance(template, AgentDefinition) and template is not None: template = AgentDefinition.from_dict(template) @@ -802,9 +907,15 @@ def __init__( if not isinstance(parameters, PropertySchema) and parameters is not None: parameters = PropertySchema.from_dict(parameters) self.parameters = parameters or PropertySchema() - converted_resources = [] - for resource in resources or []: - if not isinstance(resource, Resource): - resource = Resource.from_dict(resource) - converted_resources.append(resource) + converted_resources: list[Resource] = [] + if isinstance(resources, list): + for resource in resources: + if not isinstance(resource, Resource): + resource = Resource.from_dict(resource) + converted_resources.append(resource) + elif isinstance(resources, dict): + for k, v in resources.items(): + temp_resource = {"name": k, **v} + resource = Resource.from_dict(temp_resource) + converted_resources.append(resource) self.resources = converted_resources diff --git a/python/packages/declarative/pyproject.toml b/python/packages/declarative/pyproject.toml index 98109ebae5..1ecdb773c4 100644 --- a/python/packages/declarative/pyproject.toml +++ b/python/packages/declarative/pyproject.toml @@ -23,6 +23,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core", + "powerfx>=0.0.31", "pyyaml>=6.0,<7.0", ] diff --git a/python/packages/declarative/tests/test_loader.py b/python/packages/declarative/tests/test_declarative_loader.py similarity index 86% rename from python/packages/declarative/tests/test_loader.py rename to python/packages/declarative/tests/test_declarative_loader.py index 9a1b8724cd..5e0a5f5b23 100644 --- a/python/packages/declarative/tests/test_loader.py +++ b/python/packages/declarative/tests/test_declarative_loader.py @@ -1,8 +1,10 @@ # Copyright (c) Microsoft. All rights reserved. +from pathlib import Path + import pytest -from agent_framework_declarative._agent_factory import load_maml +from agent_framework_declarative._loader import load_maml from agent_framework_declarative._models import ( AgentDefinition, AgentManifest, @@ -420,3 +422,37 @@ def test_load_maml_property_schema_with_nested_properties(): assert result.properties[0].kind == "property" assert result.properties[1].kind == "object" assert result.properties[2].kind == "array" + + +def test_load_maml_agent_samples(): + """Test that load_maml successfully loads all YAML files from agent-samples directory.""" + # Find agent-samples directory (should be at repo root level, parallel to python/) + current_file = Path(__file__) + repo_root = current_file.parent.parent.parent.parent # tests -> declarative -> packages -> python + agent_samples_dir = repo_root.parent / "agent-samples" + + # Skip test if agent-samples directory doesn't exist + if not agent_samples_dir.exists(): + pytest.skip(f"agent-samples directory not found at {agent_samples_dir}") + + # Find all YAML files in agent-samples directory + yaml_files = list(agent_samples_dir.rglob("*.yaml")) + list(agent_samples_dir.rglob("*.yml")) + + assert len(yaml_files) > 0, f"No YAML files found in {agent_samples_dir}" + + # Test each YAML file + errors = [] + for yaml_file in yaml_files: + try: + with open(yaml_file) as f: + content = f.read() + result = load_maml(content) + # Result can be None for unknown kinds, but should not raise exceptions + assert result is not None, f"load_maml returned None for {yaml_file.relative_to(agent_samples_dir)}" + except Exception as e: + errors.append(f"{yaml_file.relative_to(agent_samples_dir)}: {e}") + + # Report all errors at once + if errors: + error_msg = "\n".join(errors) + pytest.fail(f"Failed to load {len(errors)} out of {len(yaml_files)} YAML files:\n{error_msg}") diff --git a/python/packages/declarative/tests/test_models.py b/python/packages/declarative/tests/test_declarative_models.py similarity index 68% rename from python/packages/declarative/tests/test_models.py rename to python/packages/declarative/tests/test_declarative_models.py index c7d97b412d..f269f53d83 100644 --- a/python/packages/declarative/tests/test_models.py +++ b/python/packages/declarative/tests/test_declarative_models.py @@ -37,6 +37,7 @@ Template, ToolResource, WebSearchTool, + _try_powerfx_eval, ) @@ -151,6 +152,31 @@ def test_object_property_from_dict(self): assert len(obj_prop.properties) == 2 assert all(isinstance(p, Property) for p in obj_prop.properties) + def test_object_property_with_dict_properties(self): + """Test ObjectProperty with dict format for properties (MAML YAML dict syntax).""" + data = { + "name": "person", + "kind": "object", + "properties": { + "name": {"kind": "string", "required": True}, + "email": {"kind": "string"}, + "age": {"kind": "integer"}, + }, + } + obj_prop = ObjectProperty.from_dict(data) + assert obj_prop.name == "person" + assert obj_prop.kind == "object" + assert len(obj_prop.properties) == 3 + + # Check that all properties were converted correctly + prop_names = {p.name for p in obj_prop.properties} + assert prop_names == {"name", "email", "age"} + + # Check specific property + name_prop = next(p for p in obj_prop.properties if p.name == "name") + assert name_prop.kind == "string" + assert name_prop.required is True + class TestPropertySchema: """Tests for PropertySchema class.""" @@ -174,6 +200,29 @@ def test_property_schema_from_dict(self): assert schema.properties[0].name == "prop1" assert schema.properties[0].kind == "string" + def test_property_schema_with_dict_properties(self): + """Test PropertySchema with dict format for properties (MAML YAML dict syntax).""" + data = { + "strict": True, + "properties": { + "firstName": {"kind": "string", "description": "First name"}, + "lastName": {"kind": "string", "description": "Last name"}, + "age": {"kind": "integer", "required": True}, + }, + } + schema = PropertySchema.from_dict(data) + assert schema.strict is True + assert len(schema.properties) == 3 + + # Check that all properties were converted correctly + prop_names = {p.name for p in schema.properties} + assert prop_names == {"firstName", "lastName", "age"} + + # Check specific property details + age_prop = next(p for p in schema.properties if p.name == "age") + assert age_prop.kind == "integer" + assert age_prop.required is True + class TestConnection: """Tests for Connection base class.""" @@ -389,6 +438,30 @@ def test_function_tool_from_dict(self): assert tool.kind == "function" assert isinstance(tool.parameters, PropertySchema) + def test_function_tool_with_dict_bindings(self): + """Test FunctionTool with dict format for bindings (MAML YAML dict syntax).""" + data = { + "name": "calculate", + "kind": "function", + "description": "Calculate something", + "bindings": { + "x": "input.x", + "y": "input.y", + "operation": "input.op", + }, + } + tool = FunctionTool.from_dict(data) + assert tool.name == "calculate" + assert len(tool.bindings) == 3 + + # Check that all bindings were converted correctly + binding_names = {b.name for b in tool.bindings} + assert binding_names == {"x", "y", "operation"} + + # Check specific binding + x_binding = next(b for b in tool.bindings if b.name == "x") + assert x_binding.input == "input.x" + class TestCustomTool: """Tests for CustomTool class.""" @@ -539,6 +612,44 @@ def test_mcp_tool_from_dict(self): assert tool.kind == "mcp" assert isinstance(tool.approvalMode, McpServerApprovalMode) + def test_mcp_tool_with_simplified_approval_mode(self): + """Test McpTool with simplified string format for approvalMode.""" + # Test simplified string format: approvalMode: "always" + data = { + "name": "mcp_tool", + "description": "An MCP tool", + "kind": "mcp", + "serverName": "test-server", + "approvalMode": "always", + } + tool = McpTool.from_dict(data) + assert tool.name == "mcp_tool" + assert tool.kind == "mcp" + assert isinstance(tool.approvalMode, McpServerApprovalMode) + assert tool.approvalMode.kind == "always" + + def test_mcp_tool_approval_mode_equivalence(self): + """Test that simplified and full format produce equivalent results.""" + # Simplified format + data_simplified = { + "name": "mcp_tool", + "kind": "mcp", + "approvalMode": "never", + } + tool_simplified = McpTool.from_dict(data_simplified) + + # Full format + data_full = { + "name": "mcp_tool", + "kind": "mcp", + "approvalMode": {"kind": "never"}, + } + tool_full = McpTool.from_dict(data_full) + + # Both should produce the same result + assert tool_simplified.approvalMode.kind == tool_full.approvalMode.kind + assert tool_simplified.approvalMode.kind == "never" + class TestOpenApiTool: """Tests for OpenApiTool class.""" @@ -722,6 +833,140 @@ def test_environment_variable_from_dict(self): assert env_var.value == "secret123" +class TestTryPowerfxEval: + """Tests for _try_powerfx_eval function.""" + + def test_no_evaluation_without_equals_prefix(self): + """Test that strings without '=' prefix are returned as-is.""" + assert _try_powerfx_eval("hello") == "hello" + assert _try_powerfx_eval("test value") == "test value" + assert _try_powerfx_eval("123") == "123" + + def test_none_value_returns_none(self): + """Test that None values are returned as None.""" + assert _try_powerfx_eval(None) is None + + def test_empty_string_returns_empty(self): + """Test that empty strings are returned as empty.""" + assert _try_powerfx_eval("") == "" + + def test_simple_powerfx_expressions(self): + """Test simple PowerFx expressions.""" + from decimal import Decimal + + # Simple math - returns Decimal + assert _try_powerfx_eval("=1 + 2") == Decimal("3") + assert _try_powerfx_eval("=10 * 5") == Decimal("50") + + # String literals + assert _try_powerfx_eval('="hello"') == "hello" + assert _try_powerfx_eval('="test value"') == "test value" + + def test_env_variable_access(self, monkeypatch): + """Test accessing environment variables using =Env. pattern.""" + # Set up test environment variables + monkeypatch.setenv("TEST_VAR", "test_value") + monkeypatch.setenv("API_KEY", "secret123") + monkeypatch.setenv("PORT", "8080") + + # Test basic env access + assert _try_powerfx_eval("=Env.TEST_VAR") == "test_value" + assert _try_powerfx_eval("=Env.API_KEY") == "secret123" + assert _try_powerfx_eval("=Env.PORT") == "8080" + + def test_env_variable_with_string_concatenation(self, monkeypatch): + """Test env variables with string concatenation operator.""" + monkeypatch.setenv("BASE_URL", "https://api.example.com") + monkeypatch.setenv("API_VERSION", "v1") + + # Test concatenation with & + result = _try_powerfx_eval('=Env.BASE_URL & "/" & Env.API_VERSION') + assert result == "https://api.example.com/v1" + + # Test concatenation with literals + result = _try_powerfx_eval('="API Key: " & Env.API_VERSION') + assert result == "API Key: v1" + + def test_string_comparison_operators(self, monkeypatch): + """Test PowerFx string comparison operators.""" + monkeypatch.setenv("ENV_MODE", "production") + + # Equal to - returns bool + assert _try_powerfx_eval('=Env.ENV_MODE = "production"') is True + assert _try_powerfx_eval('=Env.ENV_MODE = "development"') is False + + # Not equal to - returns bool + assert _try_powerfx_eval('=Env.ENV_MODE <> "development"') is True + assert _try_powerfx_eval('=Env.ENV_MODE <> "production"') is False + + def test_string_in_operator(self): + """Test PowerFx 'in' operator for substring testing (case-insensitive).""" + # Substring test - case insensitive - returns bool + assert _try_powerfx_eval('="the" in "The keyboard and the monitor"') is True + assert _try_powerfx_eval('="THE" in "The keyboard and the monitor"') is True + assert _try_powerfx_eval('="xyz" in "The keyboard and the monitor"') is False + + def test_string_exactin_operator(self): + """Test PowerFx 'exactin' operator for substring testing (case-sensitive).""" + # Substring test - case sensitive - returns bool + assert _try_powerfx_eval('="Windows" exactin "To display windows in the Windows operating system"') is True + assert _try_powerfx_eval('="windows" exactin "To display windows in the Windows operating system"') is True + assert _try_powerfx_eval('="WINDOWS" exactin "To display windows in the Windows operating system"') is False + + def test_logical_operators_with_strings(self): + """Test PowerFx logical operators (And, Or, Not) with string comparisons.""" + # And operator - returns bool + assert _try_powerfx_eval('="a" = "a" And "b" = "b"') is True + assert _try_powerfx_eval('="a" = "a" And "b" = "c"') is False + + # && operator (alternative syntax) - returns bool + assert _try_powerfx_eval('="a" = "a" && "b" = "b"') is True + + # Or operator - returns bool + assert _try_powerfx_eval('="a" = "b" Or "c" = "c"') is True + assert _try_powerfx_eval('="a" = "b" Or "c" = "d"') is False + + # || operator (alternative syntax) - returns bool + assert _try_powerfx_eval('="a" = "b" || "c" = "c"') is True + + # Not operator - returns bool + assert _try_powerfx_eval('=Not("a" = "b")') is True + assert _try_powerfx_eval('=Not("a" = "a")') is False + + # ! operator (alternative syntax) - returns bool + assert _try_powerfx_eval('=!("a" = "b")') is True + + def test_invalid_expressions_return_none(self): + """Test that invalid PowerFx expressions return None.""" + # Syntax errors should return None + assert _try_powerfx_eval("=invalid syntax !!!") is None + assert _try_powerfx_eval("=Env.NONEXISTENT_VAR") is None + assert _try_powerfx_eval("=1 / 0") is None # Division by zero + + def test_parentheses_for_precedence(self): + """Test using parentheses to control operator precedence.""" + from decimal import Decimal + + # Test arithmetic precedence - returns Decimal + assert _try_powerfx_eval("=(1 + 2) * 3") == Decimal("9") + assert _try_powerfx_eval("=1 + 2 * 3") == Decimal("7") + + # Test logical precedence - returns bool + result = _try_powerfx_eval('=("a" = "a" Or "b" = "c") And "d" = "d"') + assert result is True + + def test_env_with_special_characters(self, monkeypatch): + """Test env variables containing special characters in values.""" + monkeypatch.setenv("URL_WITH_QUERY", "https://example.com?param=value") + monkeypatch.setenv("PATH_WITH_SPACES", "C:\\Program Files\\App") + + result = _try_powerfx_eval("=Env.URL_WITH_QUERY") + assert result == "https://example.com?param=value" + + result = _try_powerfx_eval("=Env.PATH_WITH_SPACES") + assert result == "C:\\Program Files\\App" + + class TestAgentManifest: """Tests for AgentManifest class.""" @@ -776,3 +1021,31 @@ def test_agent_manifest_complete(self): assert isinstance(manifest.template, AgentDefinition) assert len(manifest.resources) == 1 assert isinstance(manifest.resources[0], ModelResource) + + def test_agent_manifest_with_dict_resources(self): + """Test AgentManifest with dict format for resources (MAML YAML dict syntax).""" + data = { + "name": "manifest-with-dict-resources", + "description": "Test manifest with dict resources", + "resources": { + "gptModelDeployment": {"kind": "model", "id": "gpt-4o"}, + "webSearchInstance": {"kind": "tool", "id": "web-search"}, + "analyticsTool": {"kind": "tool", "id": "analytics"}, + }, + } + manifest = AgentManifest.from_dict(data) + assert manifest.name == "manifest-with-dict-resources" + assert len(manifest.resources) == 3 + + # Check that all resources were converted correctly + resource_names = {r.name for r in manifest.resources} + assert resource_names == {"gptModelDeployment", "webSearchInstance", "analyticsTool"} + + # Check specific resource + gpt_resource = next(r for r in manifest.resources if r.name == "gptModelDeployment") + assert isinstance(gpt_resource, ModelResource) + assert gpt_resource.id == "gpt-4o" + + web_resource = next(r for r in manifest.resources if r.name == "webSearchInstance") + assert isinstance(web_resource, ToolResource) + assert web_resource.id == "web-search" diff --git a/python/samples/getting_started/declarative/azure_openai_responses_agent.py b/python/samples/getting_started/declarative/azure_openai_responses_agent.py new file mode 100644 index 0000000000..37c9d00455 --- /dev/null +++ b/python/samples/getting_started/declarative/azure_openai_responses_agent.py @@ -0,0 +1,27 @@ +# Copyright (c) Microsoft. All rights reserved. +import asyncio +from pathlib import Path + +from agent_framework.declarative import AgentFactory +from azure.identity import AzureCliCredential + + +async def main(): + """Create an agent from a declarative yaml specification and run it.""" + # get the path + current_path = Path(__file__).parent + yaml_path = current_path.parent.parent.parent.parent / "agent-samples" / "azure" / "AzureOpenAIResponses.yaml" + + # load the yaml from the path + with yaml_path.open("r") as f: + yaml_str = f.read() + + # create the agent from the yaml + agent = AgentFactory(client_kwargs={"credential": AzureCliCredential()}).create_agent_from_yaml(yaml_str) + # use the agent + response = await agent.run("Why is the sky blue, answer in Dutch?") + print("Agent response:", response.value.model_dump_json(indent=2)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/declarative/get_weather_agent.py b/python/samples/getting_started/declarative/get_weather_agent.py new file mode 100644 index 0000000000..02c3e358bd --- /dev/null +++ b/python/samples/getting_started/declarative/get_weather_agent.py @@ -0,0 +1,40 @@ +# Copyright (c) Microsoft. All rights reserved. +import asyncio +from pathlib import Path +from random import randint +from typing import Literal + +from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework_declarative import AgentFactory +from azure.identity import AzureCliCredential + + +def get_weather(location: str, unit: Literal["celsius", "fahrenheit"] = "celsius") -> str: + """A simple function tool to get weather information.""" + return f"The weather in {location} is {randint(-10, 30) if unit == 'celsius' else randint(30, 100)} degrees {unit}." + + +async def main(): + """Create an agent from a declarative yaml specification and run it.""" + # get the path + current_path = Path(__file__).parent + yaml_path = current_path.parent.parent.parent.parent / "agent-samples" / "chatclient" / "GetWeather.yaml" + + # load the yaml from the path + with yaml_path.open("r") as f: + yaml_str = f.read() + + # create the AgentFactory with a chat client and bindings + agent_factory = AgentFactory( + AzureOpenAIResponsesClient(credential=AzureCliCredential()), + bindings={"get_weather": get_weather}, + ) + # create the agent from the yaml + agent = agent_factory.create_agent_from_yaml(yaml_str) + # use the agent + response = await agent.run("What's the weather in Amsterdam, in celsius?") + print("Agent response:", response.text) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/declarative/microsoft_learn_agent.py b/python/samples/getting_started/declarative/microsoft_learn_agent.py new file mode 100644 index 0000000000..7e2c84fd9b --- /dev/null +++ b/python/samples/getting_started/declarative/microsoft_learn_agent.py @@ -0,0 +1,25 @@ +# Copyright (c) Microsoft. All rights reserved. +import asyncio +from pathlib import Path + +from agent_framework_declarative import AgentFactory +from azure.identity.aio import AzureCliCredential + + +async def main(): + """Create an agent from a declarative yaml specification and run it.""" + # get the path + current_path = Path(__file__).parent + yaml_path = current_path.parent.parent.parent.parent / "agent-samples" / "foundry" / "MicrosoftLearnAgent.yaml" + + # create the agent from the yaml + async with ( + AzureCliCredential() as credential, + AgentFactory(client_kwargs={"async_credential": credential}).create_agent_from_yaml_path(yaml_path) as agent, + ): + response = await agent.run("How do I create a storage account with private endpoint using bicep?") + print("Agent response:", response.text) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/declarative/openai_responses_agent.py b/python/samples/getting_started/declarative/openai_responses_agent.py new file mode 100644 index 0000000000..115e5e9e01 --- /dev/null +++ b/python/samples/getting_started/declarative/openai_responses_agent.py @@ -0,0 +1,26 @@ +# Copyright (c) Microsoft. All rights reserved. +import asyncio +from pathlib import Path + +from agent_framework_declarative import AgentFactory + + +async def main(): + """Create an agent from a declarative yaml specification and run it.""" + # get the path + current_path = Path(__file__).parent + yaml_path = current_path.parent.parent.parent.parent / "agent-samples" / "openai" / "OpenAIResponses.yaml" + + # load the yaml from the path + with yaml_path.open("r") as f: + yaml_str = f.read() + + # create the agent from the yaml + agent = AgentFactory().create_agent_from_yaml(yaml_str) + # use the agent + response = await agent.run("Why is the sky blue, answer in Dutch?") + print("Agent response:", response.value) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/declarative/simple_agent.py b/python/samples/getting_started/declarative/simple_agent.py deleted file mode 100644 index 8f0e9c46e0..0000000000 --- a/python/samples/getting_started/declarative/simple_agent.py +++ /dev/null @@ -1,19 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -from pathlib import Path - -from agent_framework_declarative import load_maml - - -def main(): - """Create an agent from a declarative yaml specification and run it.""" - current_path = Path(__file__).parent - yaml_path = current_path.parent.parent.parent.parent / "agent-samples" / "chatclient" / "Assistant.yaml" - - with yaml_path.open("r") as f: - yaml_str = f.read() - agent = load_maml(yaml_str) - print(agent) - - -if __name__ == "__main__": - main() diff --git a/python/uv.lock b/python/uv.lock index e64ab5cb68..9de7ba148a 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -344,12 +344,14 @@ version = "1.0.0b251028" source = { editable = "packages/declarative" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "powerfx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, + { name = "powerfx", specifier = ">=0.0.31" }, { name = "pyyaml", specifier = ">=6.0,<7.0" }, ] @@ -1258,6 +1260,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, ] +[[package]] +name = "clr-loader" +version = "0.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/88/9e0a80d59b28d394aad5d736bd47e5aa5883cf1d3674b313ba93e2a353e4/clr_loader-0.2.8.tar.gz", hash = "sha256:b4cd3a2ee5f0489885ef07ffd87eb38b2cee24ca65dcacea97b34e7b59913814", size = 61502, upload-time = "2025-10-20T21:03:16.548Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/2d/748c97ed6a4e8ae38666fd2c42967296222b7902321cff939f60d5a72b55/clr_loader-0.2.8-py3-none-any.whl", hash = "sha256:2cd76904e2f68fecab1ad1d158fb2905b5173a2b0cd54606d548518642bfbce9", size = 56412, upload-time = "2025-10-20T21:02:47.476Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -4881,6 +4895,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6c/a0/4ed6632b70a52de845df056654162acdebaf97c20e3212c559ac43e7216e/python_ulid-3.1.0-py3-none-any.whl", hash = "sha256:e2cdc979c8c877029b4b7a38a6fba3bc4578e4f109a308419ff4d3ccf0a46619", size = 11577, upload-time = "2025-08-18T16:09:25.047Z" }, ] +[[package]] +name = "pythonnet" +version = "3.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "clr-loader", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/d6/1afd75edd932306ae9bd2c2d961d603dc2b52fcec51b04afea464f1f6646/pythonnet-3.0.5.tar.gz", hash = "sha256:48e43ca463941b3608b32b4e236db92d8d40db4c58a75ace902985f76dac21cf", size = 239212, upload-time = "2024-12-13T08:30:44.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/f1/bfb6811df4745f92f14c47a29e50e89a36b1533130fcc56452d4660bd2d6/pythonnet-3.0.5-py3-none-any.whl", hash = "sha256:f6702d694d5d5b163c9f3f5cc34e0bed8d6857150237fae411fefb883a656d20", size = 297506, upload-time = "2024-12-13T08:30:40.661Z" }, +] + [[package]] name = "pytz" version = "2025.2" From 68ab87b0b241b76936844f11dfae23994b8081a3 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Fri, 7 Nov 2025 16:02:35 +0100 Subject: [PATCH 03/17] fix tests and mypy --- .../declarative/agent_framework_declarative/_models.py | 4 ++-- python/packages/declarative/pyproject.toml | 6 +++--- .../packages/declarative/tests/test_declarative_models.py | 7 ++++--- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/python/packages/declarative/agent_framework_declarative/_models.py b/python/packages/declarative/agent_framework_declarative/_models.py index ef9c71d636..a953d7455c 100644 --- a/python/packages/declarative/agent_framework_declarative/_models.py +++ b/python/packages/declarative/agent_framework_declarative/_models.py @@ -541,10 +541,10 @@ def __init__( description=description, bindings=bindings, ) - if isinstance(parameters, (list, dict)): + if isinstance(parameters, list): # If parameters is a list, wrap it in a PropertySchema parameters = PropertySchema(properties=parameters) - elif not isinstance(parameters, PropertySchema) and parameters is not None: + elif isinstance(parameters, dict) or (not isinstance(parameters, PropertySchema) and parameters is not None): parameters = PropertySchema.from_dict(parameters) self.parameters = parameters self.strict = strict diff --git a/python/packages/declarative/pyproject.toml b/python/packages/declarative/pyproject.toml index 1ecdb773c4..9c7706e8a1 100644 --- a/python/packages/declarative/pyproject.toml +++ b/python/packages/declarative/pyproject.toml @@ -74,15 +74,15 @@ disallow_incomplete_defs = true disallow_untyped_decorators = true [tool.bandit] -targets = ["agent_framework_anthropic"] +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_anthropic" -test = "pytest --cov=agent_framework_anthropic --cov-report=term-missing:skip-covered tests" +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"] diff --git a/python/packages/declarative/tests/test_declarative_models.py b/python/packages/declarative/tests/test_declarative_models.py index f269f53d83..94b85176d4 100644 --- a/python/packages/declarative/tests/test_declarative_models.py +++ b/python/packages/declarative/tests/test_declarative_models.py @@ -73,7 +73,7 @@ def test_property_creation(self): required=True, default="default_value", example="example_value", - enumValues=["val1", "val2"], + enum=["val1", "val2"], ) assert prop.name == "test_prop" assert prop.kind == "string" @@ -81,7 +81,7 @@ def test_property_creation(self): assert prop.required is True assert prop.default == "default_value" assert prop.example == "example_value" - assert prop.enumValues == ["val1", "val2"] + assert prop.enum == ["val1", "val2"] def test_property_from_dict(self): data = { @@ -431,7 +431,8 @@ def test_function_tool_from_dict(self): "name": "my_function", "description": "A test function", "kind": "function", - "parameters": {"strict": False, "properties": []}, + "parameters": {"properties": []}, + "strict": False, } tool = FunctionTool.from_dict(data) assert tool.name == "my_function" From 03aa16278b0e22ed272d06f6e2eb36a954ed0fb2 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Fri, 7 Nov 2025 16:15:57 +0100 Subject: [PATCH 04/17] fix parameters of functiontool --- agent-samples/chatclient/GetWeather.yaml | 23 +++++---- .../agent_framework_declarative/_models.py | 4 +- .../tests/test_declarative_loader.py | 50 +++++++++++-------- .../tests/test_declarative_models.py | 2 - 4 files changed, 42 insertions(+), 37 deletions(-) diff --git a/agent-samples/chatclient/GetWeather.yaml b/agent-samples/chatclient/GetWeather.yaml index a2b203b813..9ed637894d 100644 --- a/agent-samples/chatclient/GetWeather.yaml +++ b/agent-samples/chatclient/GetWeather.yaml @@ -13,14 +13,15 @@ tools: bindings: get_weather: get_weather parameters: - location: - kind: string - description: The city and state, e.g. San Francisco, CA - required: true - unit: - kind: string - description: The unit of temperature. Possible values are 'celsius' and 'fahrenheit'. - required: false - enum: - - celsius - - fahrenheit + properties: + location: + kind: string + description: The city and state, e.g. San Francisco, CA + required: true + unit: + kind: string + description: The unit of temperature. Possible values are 'celsius' and 'fahrenheit'. + required: false + enum: + - celsius + - fahrenheit diff --git a/python/packages/declarative/agent_framework_declarative/_models.py b/python/packages/declarative/agent_framework_declarative/_models.py index a953d7455c..8a44f0b61b 100644 --- a/python/packages/declarative/agent_framework_declarative/_models.py +++ b/python/packages/declarative/agent_framework_declarative/_models.py @@ -532,7 +532,7 @@ def __init__( kind: str = "function", description: str | None = None, bindings: list[Binding] | None = None, - parameters: PropertySchema | list[Property] | None = None, + parameters: PropertySchema | list[Property] | dict[str, Any] | None = None, strict: bool = False, ) -> None: super().__init__( @@ -544,7 +544,7 @@ def __init__( if isinstance(parameters, list): # If parameters is a list, wrap it in a PropertySchema parameters = PropertySchema(properties=parameters) - elif isinstance(parameters, dict) or (not isinstance(parameters, PropertySchema) and parameters is not None): + elif not isinstance(parameters, PropertySchema) and parameters is not None: parameters = PropertySchema.from_dict(parameters) self.parameters = parameters self.strict = strict diff --git a/python/packages/declarative/tests/test_declarative_loader.py b/python/packages/declarative/tests/test_declarative_loader.py index 5e0a5f5b23..93b5767eb4 100644 --- a/python/packages/declarative/tests/test_declarative_loader.py +++ b/python/packages/declarative/tests/test_declarative_loader.py @@ -424,35 +424,41 @@ def test_load_maml_property_schema_with_nested_properties(): assert result.properties[2].kind == "array" -def test_load_maml_agent_samples(): - """Test that load_maml successfully loads all YAML files from agent-samples directory.""" - # Find agent-samples directory (should be at repo root level, parallel to python/) +def _get_agent_sample_yaml_files(): + """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" - # Skip test if agent-samples directory doesn't exist if not agent_samples_dir.exists(): - pytest.skip(f"agent-samples directory not found at {agent_samples_dir}") + return [] - # Find all YAML files in agent-samples directory 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] - assert len(yaml_files) > 0, f"No YAML files found in {agent_samples_dir}" - # Test each YAML file - errors = [] - for yaml_file in yaml_files: - try: - with open(yaml_file) as f: - content = f.read() - result = load_maml(content) - # Result can be None for unknown kinds, but should not raise exceptions - assert result is not None, f"load_maml returned None for {yaml_file.relative_to(agent_samples_dir)}" - except Exception as e: - errors.append(f"{yaml_file.relative_to(agent_samples_dir)}: {e}") +@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_load_maml_agent_samples(yaml_file: Path, agent_samples_dir: Path): + """Test that load_maml successfully loads a YAML file from agent-samples directory.""" + with open(yaml_file) as f: + content = f.read() + result = load_maml(content) + # Result can be None for unknown kinds, but should not raise exceptions + assert result is not None, f"load_maml returned None for {yaml_file.relative_to(agent_samples_dir)}" - # Report all errors at once - if errors: - error_msg = "\n".join(errors) - pytest.fail(f"Failed to load {len(errors)} out of {len(yaml_files)} YAML files:\n{error_msg}") + +def test_agent_samples_directory_exists(): + """Test that the agent-samples directory exists and contains YAML files.""" + 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(): + pytest.skip(f"agent-samples directory not found at {agent_samples_dir}") + + yaml_files = _get_agent_sample_yaml_files() + assert len(yaml_files) > 0, f"No YAML files found in {agent_samples_dir}" diff --git a/python/packages/declarative/tests/test_declarative_models.py b/python/packages/declarative/tests/test_declarative_models.py index 94b85176d4..4655bdec0a 100644 --- a/python/packages/declarative/tests/test_declarative_models.py +++ b/python/packages/declarative/tests/test_declarative_models.py @@ -431,13 +431,11 @@ def test_function_tool_from_dict(self): "name": "my_function", "description": "A test function", "kind": "function", - "parameters": {"properties": []}, "strict": False, } tool = FunctionTool.from_dict(data) assert tool.name == "my_function" assert tool.kind == "function" - assert isinstance(tool.parameters, PropertySchema) def test_function_tool_with_dict_bindings(self): """Test FunctionTool with dict format for bindings (MAML YAML dict syntax).""" From 674c863c99253b32695aa3b5cbd37ed95ea406d4 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Fri, 7 Nov 2025 16:34:05 +0100 Subject: [PATCH 05/17] slight logic improvement --- .../agent_framework_declarative/_loader.py | 8 ++--- .../agent_framework_declarative/_models.py | 29 ++++++++++--------- python/packages/declarative/pyproject.toml | 2 +- .../tests/test_declarative_loader.py | 16 ++-------- .../tests/test_declarative_models.py | 6 ++++ .../declarative/get_weather_agent.py | 2 +- .../declarative/microsoft_learn_agent.py | 2 +- .../declarative/openai_responses_agent.py | 2 +- python/uv.lock | 8 ++--- 9 files changed, 36 insertions(+), 39 deletions(-) diff --git a/python/packages/declarative/agent_framework_declarative/_loader.py b/python/packages/declarative/agent_framework_declarative/_loader.py index 27a96b5e65..7dc32163cb 100644 --- a/python/packages/declarative/agent_framework_declarative/_loader.py +++ b/python/packages/declarative/agent_framework_declarative/_loader.py @@ -122,7 +122,7 @@ class ProviderLookupError(DeclarativeLoaderError): pass -def load_maml(yaml_str: str) -> Any: +def load_maml(yaml_str: str) -> Any | None: """Load a MAML object from a YAML string. This function can parse any MAML object type and return the appropriate @@ -137,12 +137,12 @@ def load_maml(yaml_str: str) -> Any: """ as_dict = yaml.safe_load(yaml_str) + kind = as_dict.get("kind", None) + # If no kind field, assume it's an AgentManifest - if "kind" not in as_dict: + if kind is None: return AgentManifest.from_dict(as_dict) - kind = as_dict["kind"] - # Match on the kind field to determine which class to instantiate match kind: # Agent types diff --git a/python/packages/declarative/agent_framework_declarative/_models.py b/python/packages/declarative/agent_framework_declarative/_models.py index 8a44f0b61b..c7f7518e3e 100644 --- a/python/packages/declarative/agent_framework_declarative/_models.py +++ b/python/packages/declarative/agent_framework_declarative/_models.py @@ -5,9 +5,14 @@ from agent_framework import get_logger from agent_framework._serialization import SerializationMixin -from powerfx import Engine -engine = Engine() +try: + from powerfx import Engine + + engine = Engine() +except ImportError: + engine = None + logger = get_logger("agent_framework.declarative") @@ -16,13 +21,15 @@ def _try_powerfx_eval(value: str | None) -> str | None: """Check if a value refers to a environment variable and parse it if so.""" if not value or not value.startswith("="): return value + if engine is None: + logger.warning( + f"PowerFx engine not available for evaluating value: {value}" + "Ensure you are on python 3.13 or less and have the powerfx package installed." + "Otherwise replace all powerfx statements in your yaml with strings." + ) + return value try: - env = dict(os.environ) - except Exception as exc: - logger.info("Failed to get environment variables for PowerFx evaluation: %s", exc) - env = {} - try: - return engine.eval(value[1:], symbols={"Env": env}) + return engine.eval(value[1:], symbols={"Env": dict(os.environ)}) except Exception as exc: logger.info("PowerFx evaluation failed for value '%s': %s", value, exc) return None @@ -78,12 +85,8 @@ def from_dict( kind = value.get("kind", "") if kind == "array": - from agent_framework_declarative._models import ArrayProperty - return ArrayProperty.from_dict(value, dependencies=dependencies) if kind == "object": - from agent_framework_declarative._models import ObjectProperty - return ObjectProperty.from_dict(value, dependencies=dependencies) # Default to Property for kind="property" or empty return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[misc] @@ -444,8 +447,6 @@ def from_dict( kind = value.get("kind", "") if kind == "Prompt" or kind == "Agent": - from agent_framework_declarative._models import PromptAgent - return PromptAgent.from_dict(value, dependencies=dependencies) # Default to AgentDefinition return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[misc] diff --git a/python/packages/declarative/pyproject.toml b/python/packages/declarative/pyproject.toml index 9c7706e8a1..4028ddc18d 100644 --- a/python/packages/declarative/pyproject.toml +++ b/python/packages/declarative/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core", - "powerfx>=0.0.31", + "powerfx>=0.0.31; python_version < '3.14'", "pyyaml>=6.0,<7.0", ] diff --git a/python/packages/declarative/tests/test_declarative_loader.py b/python/packages/declarative/tests/test_declarative_loader.py index 93b5767eb4..545869adff 100644 --- a/python/packages/declarative/tests/test_declarative_loader.py +++ b/python/packages/declarative/tests/test_declarative_loader.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. +import sys from pathlib import Path import pytest @@ -34,6 +35,8 @@ WebSearchTool, ) +pytestmark = pytest.mark.skipif(sys.version_info >= (3, 14), reason="Skipping on Python 3.14+") + @pytest.mark.parametrize( "yaml_content,expected_type,expected_attributes", @@ -449,16 +452,3 @@ def test_load_maml_agent_samples(yaml_file: Path, agent_samples_dir: Path): result = load_maml(content) # Result can be None for unknown kinds, but should not raise exceptions assert result is not None, f"load_maml returned None for {yaml_file.relative_to(agent_samples_dir)}" - - -def test_agent_samples_directory_exists(): - """Test that the agent-samples directory exists and contains YAML files.""" - 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(): - pytest.skip(f"agent-samples directory not found at {agent_samples_dir}") - - yaml_files = _get_agent_sample_yaml_files() - assert len(yaml_files) > 0, f"No YAML files found in {agent_samples_dir}" diff --git a/python/packages/declarative/tests/test_declarative_models.py b/python/packages/declarative/tests/test_declarative_models.py index 4655bdec0a..b4779c6d8e 100644 --- a/python/packages/declarative/tests/test_declarative_models.py +++ b/python/packages/declarative/tests/test_declarative_models.py @@ -2,6 +2,10 @@ """Tests for MAML model classes.""" +import sys + +import pytest + from agent_framework_declarative._models import ( AgentDefinition, AgentManifest, @@ -40,6 +44,8 @@ _try_powerfx_eval, ) +pytestmark = pytest.mark.skipif(sys.version_info >= (3, 14), reason="Skipping on Python 3.14+") + class TestBinding: """Tests for Binding class.""" diff --git a/python/samples/getting_started/declarative/get_weather_agent.py b/python/samples/getting_started/declarative/get_weather_agent.py index 02c3e358bd..3994d8ede7 100644 --- a/python/samples/getting_started/declarative/get_weather_agent.py +++ b/python/samples/getting_started/declarative/get_weather_agent.py @@ -5,7 +5,7 @@ from typing import Literal from agent_framework.azure import AzureOpenAIResponsesClient -from agent_framework_declarative import AgentFactory +from agent_framework.declarative import AgentFactory from azure.identity import AzureCliCredential diff --git a/python/samples/getting_started/declarative/microsoft_learn_agent.py b/python/samples/getting_started/declarative/microsoft_learn_agent.py index 7e2c84fd9b..bdbda4f76f 100644 --- a/python/samples/getting_started/declarative/microsoft_learn_agent.py +++ b/python/samples/getting_started/declarative/microsoft_learn_agent.py @@ -2,7 +2,7 @@ import asyncio from pathlib import Path -from agent_framework_declarative import AgentFactory +from agent_framework.declarative import AgentFactory from azure.identity.aio import AzureCliCredential diff --git a/python/samples/getting_started/declarative/openai_responses_agent.py b/python/samples/getting_started/declarative/openai_responses_agent.py index 115e5e9e01..ee3b48787e 100644 --- a/python/samples/getting_started/declarative/openai_responses_agent.py +++ b/python/samples/getting_started/declarative/openai_responses_agent.py @@ -2,7 +2,7 @@ import asyncio from pathlib import Path -from agent_framework_declarative import AgentFactory +from agent_framework.declarative import AgentFactory async def main(): diff --git a/python/uv.lock b/python/uv.lock index 9de7ba148a..b9dbff4ce9 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -344,14 +344,14 @@ version = "1.0.0b251028" source = { editable = "packages/declarative" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "powerfx", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "powerfx", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "powerfx", specifier = ">=0.0.31" }, + { name = "powerfx", marker = "python_full_version < '3.14'", specifier = ">=0.0.31" }, { name = "pyyaml", specifier = ">=6.0,<7.0" }, ] @@ -1265,7 +1265,7 @@ name = "clr-loader" version = "0.2.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "cffi", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32') or (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')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e8/88/9e0a80d59b28d394aad5d736bd47e5aa5883cf1d3674b313ba93e2a353e4/clr_loader-0.2.8.tar.gz", hash = "sha256:b4cd3a2ee5f0489885ef07ffd87eb38b2cee24ca65dcacea97b34e7b59913814", size = 61502, upload-time = "2025-10-20T21:03:16.548Z" } wheels = [ @@ -4900,7 +4900,7 @@ name = "pythonnet" version = "3.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "clr-loader", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "clr-loader", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32') or (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')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9a/d6/1afd75edd932306ae9bd2c2d961d603dc2b52fcec51b04afea464f1f6646/pythonnet-3.0.5.tar.gz", hash = "sha256:48e43ca463941b3608b32b4e236db92d8d40db4c58a75ace902985f76dac21cf", size = 239212, upload-time = "2024-12-13T08:30:44.393Z" } wheels = [ From aa90a632364ef3135a36e4f79dd0a043c94f866e Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Fri, 7 Nov 2025 16:39:07 +0100 Subject: [PATCH 06/17] remove path until merge --- agent-samples/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent-samples/README.md b/agent-samples/README.md index 91e45605db..58b5696464 100644 --- a/agent-samples/README.md +++ b/agent-samples/README.md @@ -1,3 +1,3 @@ # Declarative Agents -This folder contains sample agent definitions than be ran using the [Declarative Agents](../dotnet/samples/GettingStarted/DeclarativeAgents) demo. +This folder contains sample agent definitions than be ran using the [Declarative Agents]() demo. From 5bf781e4ed41222442b1fc7873143044450887fa Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Mon, 10 Nov 2025 11:08:24 +0100 Subject: [PATCH 07/17] updates from comments --- python/.cspell.json | 1 - .../agent_framework_declarative/_loader.py | 14 +++---- .../tests/test_declarative_loader.py | 38 +++++++++---------- 3 files changed, 26 insertions(+), 27 deletions(-) diff --git a/python/.cspell.json b/python/.cspell.json index 76332bdb12..da81b69a3b 100644 --- a/python/.cspell.json +++ b/python/.cspell.json @@ -45,7 +45,6 @@ "logit", "logprobs", "lowlevel", - "maml", "Magentic", "mistralai", "mongocluster", diff --git a/python/packages/declarative/agent_framework_declarative/_loader.py b/python/packages/declarative/agent_framework_declarative/_loader.py index 7dc32163cb..ab0c447f4f 100644 --- a/python/packages/declarative/agent_framework_declarative/_loader.py +++ b/python/packages/declarative/agent_framework_declarative/_loader.py @@ -87,9 +87,9 @@ class ProviderTypeMapping(TypedDict, total=True): "name": "OpenAIChatClient", "model_id_field": "model_id", }, - "OpenAI.Completions": { + "OpenAI.Assistants": { "package": "agent_framework.openai", - "name": "OpenAICompletionsClient", + "name": "OpenAIAssistantsClient", "model_id_field": "model_id", }, "OpenAI.Responses": { @@ -122,7 +122,7 @@ class ProviderLookupError(DeclarativeLoaderError): pass -def load_maml(yaml_str: str) -> Any | None: +def load_yaml_spec(yaml_str: str) -> Any | None: """Load a MAML object from a YAML string. This function can parse any MAML object type and return the appropriate @@ -275,7 +275,7 @@ def __init__( def create_agent_from_yaml_path(self, yaml_path: str | Path) -> ChatAgent: """Create a MAML object from a YAML file path asynchronously. - This method wraps the synchronous load_maml function to provide + This method wraps the synchronous load_yaml_spec function to provide asynchronous behavior. Args: @@ -300,11 +300,11 @@ def create_agent_from_yaml_path(self, yaml_path: str | Path) -> ChatAgent: def create_agent_from_yaml(self, yaml_str: str) -> ChatAgent: """Create a MAML object from a YAML string asynchronously. - This method wraps the synchronous load_maml function to provide + This method wraps the synchronous load_yaml_spec function to provide asynchronous behavior. This method does the following things: - 1. Loads the YAML string into a MAML object using load_maml. + 1. Loads the YAML string into a MAML object using load_yaml_spec. 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. @@ -322,7 +322,7 @@ def create_agent_from_yaml(self, yaml_str: str) -> ChatAgent: 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 = load_maml(yaml_str) + prompt_agent = load_yaml_spec(yaml_str) if not isinstance(prompt_agent, PromptAgent): raise DeclarativeLoaderError("Only PromptAgent kind is supported for agent creation") diff --git a/python/packages/declarative/tests/test_declarative_loader.py b/python/packages/declarative/tests/test_declarative_loader.py index 545869adff..ff66395fe2 100644 --- a/python/packages/declarative/tests/test_declarative_loader.py +++ b/python/packages/declarative/tests/test_declarative_loader.py @@ -5,7 +5,7 @@ import pytest -from agent_framework_declarative._loader import load_maml +from agent_framework_declarative._loader import load_yaml_spec from agent_framework_declarative._models import ( AgentDefinition, AgentManifest, @@ -286,9 +286,9 @@ ), ], ) -def test_load_maml_all_types(yaml_content, expected_type, expected_attributes): - """Test that load_maml correctly loads all MAML object types.""" - result = load_maml(yaml_content) +def test_load_yaml_spec_all_types(yaml_content, expected_type, expected_attributes): + """Test that load_yaml_spec correctly loads all MAML object types.""" + result = load_yaml_spec(yaml_content) # Check the type is correct assert isinstance(result, expected_type), f"Expected {expected_type.__name__}, got {type(result).__name__}" @@ -301,17 +301,17 @@ def test_load_maml_all_types(yaml_content, expected_type, expected_attributes): ) -def test_load_maml_unknown_kind(): - """Test that load_maml returns None for unknown kind.""" +def test_load_yaml_spec_unknown_kind(): + """Test that load_yaml_spec returns None for unknown kind.""" yaml_content = """ kind: unknown_type name: test """ - result = load_maml(yaml_content) + result = load_yaml_spec(yaml_content) assert result is None -def test_load_maml_complex_agent_manifest(): +def test_load_yaml_spec_complex_agent_manifest(): """Test loading a complex agent manifest with nested objects.""" yaml_content = """ name: complex-manifest @@ -338,7 +338,7 @@ def test_load_maml_complex_agent_manifest(): name: tool1 id: search """ - result = load_maml(yaml_content) + result = load_yaml_spec(yaml_content) assert isinstance(result, AgentManifest) assert result.name == "complex-manifest" @@ -350,7 +350,7 @@ def test_load_maml_complex_agent_manifest(): assert isinstance(result.resources[1], ToolResource) -def test_load_maml_prompt_agent_with_tools(): +def test_load_yaml_spec_prompt_agent_with_tools(): """Test loading a prompt agent with multiple tools.""" yaml_content = """ kind: Prompt @@ -369,7 +369,7 @@ def test_load_maml_prompt_agent_with_tools(): name: code description: Execute code """ - result = load_maml(yaml_content) + result = load_yaml_spec(yaml_content) assert isinstance(result, PromptAgent) assert result.name == "multi-tool-agent" @@ -380,20 +380,20 @@ def test_load_maml_prompt_agent_with_tools(): assert result.tools[2].kind == "code_interpreter" -def test_load_maml_model_resource(): +def test_load_yaml_spec_model_resource(): """Test loading a model resource.""" yaml_content = """ kind: Model name: my-model id: gpt-4 """ - result = load_maml(yaml_content) + result = load_yaml_spec(yaml_content) assert isinstance(result, ModelResource) assert result.id == "gpt-4" -def test_load_maml_property_schema_with_nested_properties(): +def test_load_yaml_spec_property_schema_with_nested_properties(): """Test loading a property schema with nested properties.""" yaml_content = """ kind: property_schema @@ -416,7 +416,7 @@ def test_load_maml_property_schema_with_nested_properties(): name: tags description: User tags """ - result = load_maml(yaml_content) + result = load_yaml_spec(yaml_content) assert isinstance(result, PropertySchema) assert result.strict is True @@ -445,10 +445,10 @@ def _get_agent_sample_yaml_files(): _get_agent_sample_yaml_files(), ids=lambda x: x[0].name if isinstance(x, tuple) else str(x), ) -def test_load_maml_agent_samples(yaml_file: Path, agent_samples_dir: Path): - """Test that load_maml successfully loads a YAML file from agent-samples directory.""" +def test_load_yaml_spec_agent_samples(yaml_file: Path, agent_samples_dir: Path): + """Test that load_yaml_spec successfully loads a YAML file from agent-samples directory.""" with open(yaml_file) as f: content = f.read() - result = load_maml(content) + result = load_yaml_spec(content) # Result can be None for unknown kinds, but should not raise exceptions - assert result is not None, f"load_maml returned None for {yaml_file.relative_to(agent_samples_dir)}" + assert result is not None, f"load_yaml_spec returned None for {yaml_file.relative_to(agent_samples_dir)}" From b8fcb84e64a964aaee34a254a58f78c15c7469b4 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Mon, 10 Nov 2025 15:50:47 +0100 Subject: [PATCH 08/17] create dispatcher and spec type, json_schema method --- .../packages/core/agent_framework/_agents.py | 1 - .../agent_framework_declarative/_loader.py | 188 ++---------------- .../agent_framework_declarative/_models.py | 156 ++++++++++++++- python/packages/declarative/pyproject.toml | 4 + .../declarative/get_weather_agent.py | 2 +- python/uv.lock | 139 ++++++++++--- 6 files changed, 287 insertions(+), 203 deletions(-) diff --git a/python/packages/core/agent_framework/_agents.py b/python/packages/core/agent_framework/_agents.py index 374ed8db3a..249aedc8a7 100644 --- a/python/packages/core/agent_framework/_agents.py +++ b/python/packages/core/agent_framework/_agents.py @@ -850,7 +850,6 @@ async def run( co = run_chat_options & ChatOptions( model_id=model_id, - allow_multiple_tool_calls=allow_multiple_tool_calls, conversation_id=thread.service_thread_id, allow_multiple_tool_calls=allow_multiple_tool_calls, frequency_penalty=frequency_penalty, diff --git a/python/packages/declarative/agent_framework_declarative/_loader.py b/python/packages/declarative/agent_framework_declarative/_loader.py index ab0c447f4f..c561f7b706 100644 --- a/python/packages/declarative/agent_framework_declarative/_loader.py +++ b/python/packages/declarative/agent_framework_declarative/_loader.py @@ -23,40 +23,15 @@ from dotenv import load_dotenv from ._models import ( - AgentDefinition, - AgentManifest, - AnonymousConnection, - ApiKeyConnection, - ArrayProperty, - Binding, CodeInterpreterTool, - Connection, - CustomTool, - EnvironmentVariable, FileSearchTool, - Format, FunctionTool, - McpServerApprovalMode, - McpServerToolAlwaysRequireApprovalMode, - McpServerToolNeverRequireApprovalMode, McpServerToolSpecifyApprovalMode, McpTool, - Model, ModelOptions, - ModelResource, - ObjectProperty, - OpenApiTool, - Parser, PromptAgent, - Property, - PropertySchema, - ProtocolVersionRecord, - ReferenceConnection, - RemoteConnection, - Resource, - Template, - ToolResource, WebSearchTool, + agent_schema_dispatch, ) @@ -122,114 +97,6 @@ class ProviderLookupError(DeclarativeLoaderError): pass -def load_yaml_spec(yaml_str: str) -> Any | None: - """Load a MAML object from a YAML string. - - This function can parse any MAML object type and return the appropriate - Python object. The type is determined by the 'kind' field in the YAML. - If no 'kind' field is present, it's assumed to be an AgentManifest. - - Args: - yaml_str: YAML string representation of a MAML object - - Returns: - The appropriate MAML object instance, or None if the kind is not recognized - """ - as_dict = yaml.safe_load(yaml_str) - - kind = as_dict.get("kind", None) - - # If no kind field, assume it's an AgentManifest - if kind is None: - return AgentManifest.from_dict(as_dict) - - # Match on the kind field to determine which class to instantiate - match kind: - # Agent types - case "Prompt": - return PromptAgent.from_dict(as_dict) - case "Agent": - return AgentDefinition.from_dict(as_dict) - - # Resource types - case "Tool": - return ToolResource.from_dict(as_dict) - case "Model": - return ModelResource.from_dict(as_dict) - case "Resource": - return Resource.from_dict(as_dict) - - # Tool types - case "function": - return FunctionTool.from_dict(as_dict) - case "custom": - return CustomTool.from_dict(as_dict) - case "web_search": - return WebSearchTool.from_dict(as_dict) - case "file_search": - return FileSearchTool.from_dict(as_dict) - case "mcp": - return McpTool.from_dict(as_dict) - case "openapi": - return OpenApiTool.from_dict(as_dict) - case "code_interpreter": - return CodeInterpreterTool.from_dict(as_dict) - - # Connection types - case "reference": - return ReferenceConnection.from_dict(as_dict) - case "remote": - return RemoteConnection.from_dict(as_dict) - case "key": - return ApiKeyConnection.from_dict(as_dict) - case "anonymous": - return AnonymousConnection.from_dict(as_dict) - case "connection": - return Connection.from_dict(as_dict) - - # Property types - case "array": - return ArrayProperty.from_dict(as_dict) - case "object": - return ObjectProperty.from_dict(as_dict) - case "property": - return Property.from_dict(as_dict) - - # MCP Server Approval Mode types - case "always": - return McpServerToolAlwaysRequireApprovalMode.from_dict(as_dict) - case "never": - return McpServerToolNeverRequireApprovalMode.from_dict(as_dict) - case "specify": - return McpServerToolSpecifyApprovalMode.from_dict(as_dict) - case "approval_mode": - return McpServerApprovalMode.from_dict(as_dict) - - # Other component types - case "binding": - return Binding.from_dict(as_dict) - case "format": - return Format.from_dict(as_dict) - case "parser": - return Parser.from_dict(as_dict) - case "template": - return Template.from_dict(as_dict) - case "model": - return Model.from_dict(as_dict) - case "model_options": - return ModelOptions.from_dict(as_dict) - case "property_schema": - return PropertySchema.from_dict(as_dict) - case "protocol_version": - return ProtocolVersionRecord.from_dict(as_dict) - case "environment_variable": - return EnvironmentVariable.from_dict(as_dict) - - # Unknown kind - case _: - return None - - class AgentFactory: def __init__( self, @@ -322,7 +189,7 @@ def create_agent_from_yaml(self, yaml_str: str) -> ChatAgent: 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 = load_yaml_spec(yaml_str) + prompt_agent = agent_schema_dispatch(yaml.safe_load(yaml_str)) if not isinstance(prompt_agent, PromptAgent): raise DeclarativeLoaderError("Only PromptAgent kind is supported for agent creation") @@ -331,7 +198,7 @@ def create_agent_from_yaml(self, yaml_str: str) -> ChatAgent: setup_dict.update(self.client_kwargs) # resolve connections: client: ChatClientProtocol | None = None - if prompt_agent.model.connection: + if prompt_agent.model and prompt_agent.model.connection: if prompt_agent.model.connection.kind == "key": setup_dict["api_key"] = prompt_agent.model.connection.apiKey if prompt_agent.model.connection.endpoint: @@ -351,7 +218,7 @@ def create_agent_from_yaml(self, yaml_str: str) -> ChatAgent: elif prompt_agent.model.connection.kind == "Anonymous": setup_dict["endpoint"] = prompt_agent.model.connection.endpoint # check if there is a model.provider and model.apiType defined - if prompt_agent.model.provider and prompt_agent.model.apiType: + if prompt_agent.model and prompt_agent.model.provider and prompt_agent.model.apiType: # lookup the provider type in the mapping class_lookup = f"{prompt_agent.model.provider}.{prompt_agent.model.apiType}" if class_lookup in PROVIDER_TYPE_OBJECT_MAPPING: @@ -364,7 +231,7 @@ def create_agent_from_yaml(self, yaml_str: str) -> ChatAgent: client = agent_class(**setup_dict) else: raise ValueError("Unsupported model provider or apiType in PromptAgent") - if not client and prompt_agent.model.id: + if not client and prompt_agent.model and prompt_agent.model.id: # assume AzureAIAgentClient mapping = self._retrieve_provider_configuration("AzureAIAgentClient") module_name = mapping["package"] @@ -414,18 +281,11 @@ def create_agent_from_yaml(self, yaml_str: str) -> ChatAgent: if binding.name in self.bindings: func = self.bindings[binding.name] break - json_schema = tool_resource.parameters.to_dict(exclude={"type"}, exclude_none=True) - new_props = {} - for prop in json_schema.get("properties", []): - prop_name = prop.pop("name") - prop["type"] = prop.pop("kind", None) - new_props[prop_name] = prop - json_schema["properties"] = new_props tools.append( AIFunction( # type: ignore name=tool_resource.name, description=tool_resource.description, - input_model=json_schema, + input_model=tool_resource.parameters.to_json_schema(), func=func, ) ) @@ -460,19 +320,20 @@ def create_agent_from_yaml(self, yaml_str: str) -> ChatAgent: ) case McpTool(): approval_mode: HostedMCPSpecificApproval | str | None = 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): - if tool_resource.approvalMode.alwaysRequireApprovalTools: - approval_mode = { - "always_require_approval": tool_resource.approvalMode.alwaysRequireApprovalTools - } - else: - approval_mode = { - "never_require_approval": tool_resource.approvalMode.neverRequireApprovalTools - } + 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): + if tool_resource.approvalMode.alwaysRequireApprovalTools: + approval_mode = { + "always_require_approval": tool_resource.approvalMode.alwaysRequireApprovalTools + } + else: + approval_mode = { + "never_require_approval": tool_resource.approvalMode.neverRequireApprovalTools + } tools.append( HostedMCPTool( name=tool_resource.name, @@ -487,14 +348,7 @@ def create_agent_from_yaml(self, yaml_str: str) -> ChatAgent: # response format if prompt_agent.outputSchema: - json_schema = prompt_agent.outputSchema.to_dict(exclude={"type"}, exclude_none=True) - new_props = {} - for prop in json_schema.get("properties", []): - prop_name = prop.pop("name") - prop["type"] = prop.pop("kind", None) - new_props[prop_name] = prop - json_schema["properties"] = new_props - pydantic_model = _create_model_from_json_schema("agent", json_schema) + pydantic_model = _create_model_from_json_schema("agent", prompt_agent.outputSchema.to_json_schema()) chat_options["response_format"] = pydantic_model # Step 3: Create the agent instance diff --git a/python/packages/declarative/agent_framework_declarative/_models.py b/python/packages/declarative/agent_framework_declarative/_models.py index c7f7518e3e..4d90e3ab07 100644 --- a/python/packages/declarative/agent_framework_declarative/_models.py +++ b/python/packages/declarative/agent_framework_declarative/_models.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. import os from collections.abc import MutableMapping -from typing import Any, TypeVar +from typing import Any, TypeVar, Union from agent_framework import get_logger from agent_framework._serialization import SerializationMixin @@ -16,10 +16,14 @@ logger = get_logger("agent_framework.declarative") +TEvalInput = TypeVar("TEvalInput", (str | None)) -def _try_powerfx_eval(value: str | None) -> str | None: + +def _try_powerfx_eval(value: TEvalInput) -> TEvalInput: """Check if a value refers to a environment variable and parse it if so.""" - if not value or not value.startswith("="): + if value is None: + return value + if not value.startswith("="): return value if engine is None: logger.warning( @@ -32,7 +36,7 @@ def _try_powerfx_eval(value: str | None) -> str | None: return engine.eval(value[1:], symbols={"Env": dict(os.environ)}) except Exception as exc: logger.info("PowerFx evaluation failed for value '%s': %s", value, exc) - return None + return value class Binding(SerializationMixin): @@ -191,6 +195,17 @@ def from_dict( kwargs = {k: v for k, v in value.items() if k not in ("type", "kind", "name", "description")} return SerializationMixin.from_dict.__func__(cls, kwargs, dependencies=dependencies) # type: ignore[misc] + def to_json_schema(self) -> dict[str, Any]: + """Get a schema out of this PropertySchema to create pydantic models.""" + json_schema = self.to_dict(exclude={"type"}, exclude_none=True) + new_props = {} + for prop in json_schema.get("properties", []): + prop_name = prop.pop("name") + prop["type"] = prop.pop("kind", None) + new_props[prop_name] = prop + json_schema["properties"] = new_props + return json_schema + class Connection(SerializationMixin): """Object representing a connection specification.""" @@ -920,3 +935,136 @@ def __init__( resource = Resource.from_dict(temp_resource) converted_resources.append(resource) self.resources = converted_resources + + +AgentSchemaSpec = Union[ + AgentManifest, + AgentDefinition, + PromptAgent, + Tool, + FunctionTool, + CustomTool, + WebSearchTool, + FileSearchTool, + McpTool, + OpenApiTool, + CodeInterpreterTool, + Resource, + ModelResource, + ToolResource, + Connection, + ReferenceConnection, + RemoteConnection, + ApiKeyConnection, + AnonymousConnection, + Property, + ArrayProperty, + ObjectProperty, + PropertySchema, + McpServerApprovalMode, + McpServerToolAlwaysRequireApprovalMode, + McpServerToolNeverRequireApprovalMode, + McpServerToolSpecifyApprovalMode, + Binding, + Format, + Parser, + Template, + Model, + ModelOptions, + ProtocolVersionRecord, + EnvironmentVariable, +] + + +def agent_schema_dispatch(schema: dict[str, Any]) -> AgentSchemaSpec | None: + """Create a component instance from a dictionary, dispatching to the appropriate class based on 'kind' field.""" + kind = schema.get("kind") + + # If no kind field, assume it's an AgentManifest + if kind is None: + return AgentManifest.from_dict(schema) + # Match on the kind field to determine which class to instantiate + match kind.lower(): + # Agent types + case "prompt": + return PromptAgent.from_dict(schema) + case "agent": + return AgentDefinition.from_dict(schema) + + # Resource types + case "tool": + return ToolResource.from_dict(schema) + case "model": + return ModelResource.from_dict(schema) + case "resource": + return Resource.from_dict(schema) + + # Tool types + case "function": + return FunctionTool.from_dict(schema) + case "custom": + return CustomTool.from_dict(schema) + case "web_search": + return WebSearchTool.from_dict(schema) + case "file_search": + return FileSearchTool.from_dict(schema) + case "mcp": + return McpTool.from_dict(schema) + case "openapi": + return OpenApiTool.from_dict(schema) + case "code_interpreter": + return CodeInterpreterTool.from_dict(schema) + + # Connection types + case "reference": + return ReferenceConnection.from_dict(schema) + case "remote": + return RemoteConnection.from_dict(schema) + case "key": + return ApiKeyConnection.from_dict(schema) + case "anonymous": + return AnonymousConnection.from_dict(schema) + case "connection": + return Connection.from_dict(schema) + + # Property types + case "array": + return ArrayProperty.from_dict(schema) + case "object": + return ObjectProperty.from_dict(schema) + case "property": + return Property.from_dict(schema) + + # MCP Server Approval Mode types + case "always": + return McpServerToolAlwaysRequireApprovalMode.from_dict(schema) + case "never": + return McpServerToolNeverRequireApprovalMode.from_dict(schema) + case "specify": + return McpServerToolSpecifyApprovalMode.from_dict(schema) + case "approval_mode": + return McpServerApprovalMode.from_dict(schema) + + # Other component types + case "binding": + return Binding.from_dict(schema) + case "format": + return Format.from_dict(schema) + case "parser": + return Parser.from_dict(schema) + case "template": + return Template.from_dict(schema) + case "model": + return Model.from_dict(schema) + case "model_options": + return ModelOptions.from_dict(schema) + case "property_schema": + return PropertySchema.from_dict(schema) + case "protocol_version": + return ProtocolVersionRecord.from_dict(schema) + case "environment_variable": + return EnvironmentVariable.from_dict(schema) + + # Unknown kind + case _: + return None diff --git a/python/packages/declarative/pyproject.toml b/python/packages/declarative/pyproject.toml index 4028ddc18d..5ef32024ae 100644 --- a/python/packages/declarative/pyproject.toml +++ b/python/packages/declarative/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "agent-framework-core", "powerfx>=0.0.31; python_version < '3.14'", "pyyaml>=6.0,<7.0", + "AgentSchema; python_version >= '3.11'", ] [tool.uv] @@ -34,6 +35,9 @@ environments = [ "sys_platform == 'linux'", "sys_platform == 'win32'" ] +[tool.uv.sources] +AgentSchema = { git = "https://github.com/microsoft/AgentSchema.git", subdirectory = "runtime/python/agentschema" } + [tool.uv-dynamic-versioning] fallback-version = "0.0.0" diff --git a/python/samples/getting_started/declarative/get_weather_agent.py b/python/samples/getting_started/declarative/get_weather_agent.py index 3994d8ede7..7c64f64a8a 100644 --- a/python/samples/getting_started/declarative/get_weather_agent.py +++ b/python/samples/getting_started/declarative/get_weather_agent.py @@ -30,7 +30,7 @@ async def main(): bindings={"get_weather": get_weather}, ) # create the agent from the yaml - agent = agent_factory.create_agent_from_yaml(yaml_str) + agent = agent_factory.create_agent_from_yaml(yaml_str, use_maml=True) # use the agent response = await agent.run("What's the weather in Amsterdam, in celsius?") print("Agent response:", response.text) diff --git a/python/uv.lock b/python/uv.lock index b9dbff4ce9..1596df519c 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -344,6 +344,7 @@ version = "1.0.0b251028" source = { editable = "packages/declarative" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "agentschema", 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 = "powerfx", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] @@ -351,6 +352,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, + { name = "agentschema", marker = "python_full_version >= '3.11'", git = "https://github.com/microsoft/AgentSchema.git?subdirectory=runtime%2Fpython%2Fagentschema" }, { name = "powerfx", marker = "python_full_version < '3.14'", specifier = ">=0.0.31" }, { name = "pyyaml", specifier = ">=6.0,<7.0" }, ] @@ -584,6 +586,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/63/3e48da56d5121ddcefef8645ad5a3446b0974154111a14bf75ea2b5b3cc3/agentops-0.4.21-py3-none-any.whl", hash = "sha256:93b098ea77bc5f64dcae5031a8292531cb446d9d66e6c7ef2f21a66d4e4fb2f0", size = 309579, upload-time = "2025-08-29T06:36:53.855Z" }, ] +[[package]] +name = "agentschema" +version = "0.1.0" +source = { git = "https://github.com/microsoft/AgentSchema.git?subdirectory=runtime%2Fpython%2Fagentschema#354032654c853d95fe7a5f24fa3d025f93e60b8c" } +dependencies = [ + { name = "aiofiles", 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 = "black", 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 = "click", 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-dotenv", 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 = "pyyaml", 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 = "ruff", 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')" }, +] + [[package]] name = "aiofiles" version = "25.1.0" @@ -1013,6 +1028,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] +[[package]] +name = "black" +version = "25.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click", 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 = "mypy-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')" }, + { name = "packaging", 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 = "pathspec", 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 = "platformdirs", 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 = "pytokens", 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/8c/ad/33adf4708633d047950ff2dfdea2e215d84ac50ef95aff14a614e4b6e9b2/black-25.11.0.tar.gz", hash = "sha256:9a323ac32f5dc75ce7470501b887250be5005a01602e931a15e45593f70f6e08", size = 655669, upload-time = "2025-11-10T01:53:50.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/d2/6caccbc96f9311e8ec3378c296d4f4809429c43a6cd2394e3c390e86816d/black-25.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ec311e22458eec32a807f029b2646f661e6859c3f61bc6d9ffb67958779f392e", size = 1743501, upload-time = "2025-11-10T01:59:06.202Z" }, + { url = "https://files.pythonhosted.org/packages/69/35/b986d57828b3f3dccbf922e2864223197ba32e74c5004264b1c62bc9f04d/black-25.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1032639c90208c15711334d681de2e24821af0575573db2810b0763bcd62e0f0", size = 1597308, upload-time = "2025-11-10T01:57:58.633Z" }, + { url = "https://files.pythonhosted.org/packages/39/8e/8b58ef4b37073f52b64a7b2dd8c9a96c84f45d6f47d878d0aa557e9a2d35/black-25.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0f7c461df55cf32929b002335883946a4893d759f2df343389c4396f3b6b37", size = 1656194, upload-time = "2025-11-10T01:57:10.909Z" }, + { url = "https://files.pythonhosted.org/packages/8d/30/9c2267a7955ecc545306534ab88923769a979ac20a27cf618d370091e5dd/black-25.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:f9786c24d8e9bd5f20dc7a7f0cdd742644656987f6ea6947629306f937726c03", size = 1347996, upload-time = "2025-11-10T01:57:22.391Z" }, + { url = "https://files.pythonhosted.org/packages/c4/62/d304786b75ab0c530b833a89ce7d997924579fb7484ecd9266394903e394/black-25.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:895571922a35434a9d8ca67ef926da6bc9ad464522a5fe0db99b394ef1c0675a", size = 1727891, upload-time = "2025-11-10T02:01:40.507Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/ffe8a006aa522c9e3f430e7b93568a7b2163f4b3f16e8feb6d8c3552761a/black-25.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb4f4b65d717062191bdec8e4a442539a8ea065e6af1c4f4d36f0cdb5f71e170", size = 1581875, upload-time = "2025-11-10T01:57:51.192Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7c8bda3108d0bb57387ac41b4abb5c08782b26da9f9c4421ef6694dac01a/black-25.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d81a44cbc7e4f73a9d6ae449ec2317ad81512d1e7dce7d57f6333fd6259737bc", size = 1642716, upload-time = "2025-11-10T01:56:51.589Z" }, + { url = "https://files.pythonhosted.org/packages/34/b9/f17dea34eecb7cc2609a89627d480fb6caea7b86190708eaa7eb15ed25e7/black-25.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:7eebd4744dfe92ef1ee349dc532defbf012a88b087bb7ddd688ff59a447b080e", size = 1352904, upload-time = "2025-11-10T01:59:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/7f/12/5c35e600b515f35ffd737da7febdb2ab66bb8c24d88560d5e3ef3d28c3fd/black-25.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:80e7486ad3535636657aa180ad32a7d67d7c273a80e12f1b4bfa0823d54e8fac", size = 1772831, upload-time = "2025-11-10T02:03:47Z" }, + { url = "https://files.pythonhosted.org/packages/1a/75/b3896bec5a2bb9ed2f989a970ea40e7062f8936f95425879bbe162746fe5/black-25.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6cced12b747c4c76bc09b4db057c319d8545307266f41aaee665540bc0e04e96", size = 1608520, upload-time = "2025-11-10T01:58:46.895Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b5/2bfc18330eddbcfb5aab8d2d720663cd410f51b2ed01375f5be3751595b0/black-25.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb2d54a39e0ef021d6c5eef442e10fd71fcb491be6413d083a320ee768329dd", size = 1682719, upload-time = "2025-11-10T01:56:55.24Z" }, + { url = "https://files.pythonhosted.org/packages/96/fb/f7dc2793a22cdf74a72114b5ed77fe3349a2e09ef34565857a2f917abdf2/black-25.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae263af2f496940438e5be1a0c1020e13b09154f3af4df0835ea7f9fe7bfa409", size = 1362684, upload-time = "2025-11-10T01:57:07.639Z" }, + { url = "https://files.pythonhosted.org/packages/ad/47/3378d6a2ddefe18553d1115e36aea98f4a90de53b6a3017ed861ba1bd3bc/black-25.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a1d40348b6621cc20d3d7530a5b8d67e9714906dfd7346338249ad9c6cedf2b", size = 1772446, upload-time = "2025-11-10T02:02:16.181Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4b/0f00bfb3d1f7e05e25bfc7c363f54dc523bb6ba502f98f4ad3acf01ab2e4/black-25.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51c65d7d60bb25429ea2bf0731c32b2a2442eb4bd3b2afcb47830f0b13e58bfd", size = 1607983, upload-time = "2025-11-10T02:02:52.502Z" }, + { url = "https://files.pythonhosted.org/packages/99/fe/49b0768f8c9ae57eb74cc10a1f87b4c70453551d8ad498959721cc345cb7/black-25.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:936c4dd07669269f40b497440159a221ee435e3fddcf668e0c05244a9be71993", size = 1682481, upload-time = "2025-11-10T01:57:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/55/17/7e10ff1267bfa950cc16f0a411d457cdff79678fbb77a6c73b73a5317904/black-25.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:f42c0ea7f59994490f4dccd64e6b2dd49ac57c7c84f38b8faab50f8759db245c", size = 1363869, upload-time = "2025-11-10T01:58:24.608Z" }, + { url = "https://files.pythonhosted.org/packages/67/c0/cc865ce594d09e4cd4dfca5e11994ebb51604328489f3ca3ae7bb38a7db5/black-25.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:35690a383f22dd3e468c85dc4b915217f87667ad9cce781d7b42678ce63c4170", size = 1771358, upload-time = "2025-11-10T02:03:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/37/77/4297114d9e2fd2fc8ab0ab87192643cd49409eb059e2940391e7d2340e57/black-25.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dae49ef7369c6caa1a1833fd5efb7c3024bb7e4499bf64833f65ad27791b1545", size = 1612902, upload-time = "2025-11-10T01:59:33.382Z" }, + { url = "https://files.pythonhosted.org/packages/de/63/d45ef97ada84111e330b2b2d45e1dd163e90bd116f00ac55927fb6bf8adb/black-25.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bd4a22a0b37401c8e492e994bce79e614f91b14d9ea911f44f36e262195fdda", size = 1680571, upload-time = "2025-11-10T01:57:04.239Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4b/5604710d61cdff613584028b4cb4607e56e148801ed9b38ee7970799dab6/black-25.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:aa211411e94fdf86519996b7f5f05e71ba34835d8f0c0f03c00a26271da02664", size = 1382599, upload-time = "2025-11-10T01:57:57.427Z" }, + { url = "https://files.pythonhosted.org/packages/00/5d/aed32636ed30a6e7f9efd6ad14e2a0b0d687ae7c8c7ec4e4a557174b895c/black-25.11.0-py3-none-any.whl", hash = "sha256:e3f562da087791e96cefcd9dda058380a442ab322a02e222add53736451f604b", size = 204918, upload-time = "2025-11-10T01:53:48.917Z" }, +] + [[package]] name = "blinker" version = "1.9.0" @@ -1054,9 +1106,9 @@ wheels = [ name = "cachetools" version = "6.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fb/44/ca1675be2a83aeee1886ab745b28cda92093066590233cc501890eb8417a/cachetools-6.2.2.tar.gz", hash = "sha256:8e6d266b25e539df852251cfd6f990b4bc3a141db73b939058d809ebd2590fc6", size = 31571, upload-time = "2025-11-13T17:42:51.465Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/7e/b975b5814bd36faf009faebe22c1072a1fa1168db34d285ef0ba071ad78c/cachetools-6.2.1.tar.gz", hash = "sha256:3f391e4bd8f8bf0931169baf7456cc822705f4e2a31f840d218f445b9a854201", size = 31325, upload-time = "2025-10-12T14:55:30.139Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/46/eb6eca305c77a4489affe1c5d8f4cae82f285d9addd8de4ec084a7184221/cachetools-6.2.2-py3-none-any.whl", hash = "sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace", size = 11503, upload-time = "2025-11-13T17:42:50.232Z" }, + { url = "https://files.pythonhosted.org/packages/96/c5/1e741d26306c42e2bf6ab740b2202872727e0f606033c9dd713f8b93f5a8/cachetools-6.2.1-py3-none-any.whl", hash = "sha256:09868944b6dde876dfd44e1d47e18484541eaf12f26f29b7af91b26cc892d701", size = 11280, upload-time = "2025-10-12T14:55:28.382Z" }, ] [[package]] @@ -1265,7 +1317,7 @@ name = "clr-loader" version = "0.2.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32') or (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 = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e8/88/9e0a80d59b28d394aad5d736bd47e5aa5883cf1d3674b313ba93e2a353e4/clr_loader-0.2.8.tar.gz", hash = "sha256:b4cd3a2ee5f0489885ef07ffd87eb38b2cee24ca65dcacea97b34e7b59913814", size = 61502, upload-time = "2025-10-20T21:03:16.548Z" } wheels = [ @@ -2439,9 +2491,9 @@ dependencies = [ { name = "typer-slim", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/44/8a/3cba668d9cd1b4e3eb6c1c3ff7bf0f74a7809bdbb5c327bcdbdbac802d23/huggingface_hub-1.1.4.tar.gz", hash = "sha256:a7424a766fffa1a11e4c1ac2040a1557e2101f86050fdf06627e7b74cc9d2ad6", size = 606842, upload-time = "2025-11-13T10:51:57.602Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/63/eeea214a6b456d8e91ac2ea73ebb83da3af9aa64716dfb6e28dd9b2e6223/huggingface_hub-1.1.2.tar.gz", hash = "sha256:7bdafc432dc12fa1f15211bdfa689a02531d2a47a3cc0d74935f5726cdbcab8e", size = 606173, upload-time = "2025-11-06T10:04:38.398Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/3f/969137c9d9428ed8bf171d27604243dd950a47cac82414826e2aebbc0a4c/huggingface_hub-1.1.4-py3-none-any.whl", hash = "sha256:867799fbd2ef338b7f8b03d038d9c0e09415dfe45bb2893b48a510d1d746daa5", size = 515580, upload-time = "2025-11-13T10:51:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/33/21/e15d90fd09b56938502a0348d566f1915f9789c5bb6c00c1402dc7259b6e/huggingface_hub-1.1.2-py3-none-any.whl", hash = "sha256:dfcfa84a043466fac60573c3e4af475490a7b0d7375b22e3817706d6659f61f7", size = 514955, upload-time = "2025-11-06T10:04:36.674Z" }, ] [[package]] @@ -3129,9 +3181,9 @@ dependencies = [ { name = "qdrant-client", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "sqlalchemy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/cd/f9047cd45952af08da8084c2297f8aad780f9ac8558631fc64b3ed235b28/mem0ai-1.0.1.tar.gz", hash = "sha256:53be77f479387e6c07508096eb6c0688150b31152613bdcf6c281246b000b14d", size = 182296, upload-time = "2025-11-13T22:32:13.658Z" } +sdist = { url = "https://files.pythonhosted.org/packages/99/02/b6c3bba83b4bb6450e6c8a07e4419b24644007588f5ef427b680addbd30f/mem0ai-1.0.0.tar.gz", hash = "sha256:8a891502e6547436adb526a59acf091cacaa689e182e186f4dd8baf185d75224", size = 177780, upload-time = "2025-10-16T10:36:23.871Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/42/120d6db33e190ef09d69428ddd2eaaa87e10f4c8243af788f5fc524748c9/mem0ai-1.0.1-py3-none-any.whl", hash = "sha256:a8eeca9688e87f175af53d463b4a3b2d552984c81e29bc656c847dc04eaf6f75", size = 275351, upload-time = "2025-11-13T22:32:11.839Z" }, + { url = "https://files.pythonhosted.org/packages/61/49/eed6e2a77bf90e37da25c9a336af6a6129b0baae76551409ee995f0a1f0c/mem0ai-1.0.0-py3-none-any.whl", hash = "sha256:107fd2990613eba34880ca6578e6cdd4a8158fd35f5b80be031b6e2b5a66a1f1", size = 268141, upload-time = "2025-10-16T10:36:21.63Z" }, ] [[package]] @@ -3665,9 +3717,9 @@ dependencies = [ { name = "types-requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b8/1f/322595f9a7ffd48afe2449bb92090eb893ba6ae4475e3ee549f64566a3a1/openai_agents-0.5.1.tar.gz", hash = "sha256:e193cd3a1b0d4f9a3f3fa9c4011c0b1f8876fa5f38bde4ae41d6a834a5791124", size = 1990900, upload-time = "2025-11-13T17:59:36.173Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/b3/c25c3b3084c113ffbd161c37ed7c02e0cc1296eb2f2d582d85c461f60c7a/openai_agents-0.5.0.tar.gz", hash = "sha256:776dde4025442164e3e860ff5b239b5c0ebc30f9445b0d75295c385a8ca1f696", size = 1958702, upload-time = "2025-11-05T05:28:37.456Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/ca/352a7167ac040f9e7d6765081f4021811914724303d1417525d50942e15e/openai_agents-0.5.1-py3-none-any.whl", hash = "sha256:7077c47d8e4230d788a18922df7cd69f13c3328a57744156195da4921c08c835", size = 231786, upload-time = "2025-11-13T17:59:32.691Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f5/c43a84a64aa3328c628cc19365dc514ce02abf31e31c861ad489d6d3075b/openai_agents-0.5.0-py3-none-any.whl", hash = "sha256:5ef062273815de197315ec760f571625d0f2766ceb83ab189ba6cdd9b26a10e9", size = 223272, upload-time = "2025-11-05T05:28:35.64Z" }, ] [[package]] @@ -4900,13 +4952,22 @@ name = "pythonnet" version = "3.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "clr-loader", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32') or (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 = "clr-loader", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9a/d6/1afd75edd932306ae9bd2c2d961d603dc2b52fcec51b04afea464f1f6646/pythonnet-3.0.5.tar.gz", hash = "sha256:48e43ca463941b3608b32b4e236db92d8d40db4c58a75ace902985f76dac21cf", size = 239212, upload-time = "2024-12-13T08:30:44.393Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cd/f1/bfb6811df4745f92f14c47a29e50e89a36b1533130fcc56452d4660bd2d6/pythonnet-3.0.5-py3-none-any.whl", hash = "sha256:f6702d694d5d5b163c9f3f5cc34e0bed8d6857150237fae411fefb883a656d20", size = 297506, upload-time = "2024-12-13T08:30:40.661Z" }, ] +[[package]] +name = "pytokens" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8d/a762be14dae1c3bf280202ba3172020b2b0b4c537f94427435f19c413b72/pytokens-0.3.0.tar.gz", hash = "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a", size = 17644, upload-time = "2025-11-05T13:36:35.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/25/d9db8be44e205a124f6c98bc0324b2bb149b7431c53877fc6d1038dddaf5/pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3", size = 12195, upload-time = "2025-11-05T13:36:33.183Z" }, +] + [[package]] name = "pytz" version = "2025.2" @@ -5354,26 +5415,26 @@ wheels = [ name = "ruff" version = "0.14.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/fa/fbb67a5780ae0f704876cb8ac92d6d76da41da4dc72b7ed3565ab18f2f52/ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1", size = 5615944, upload-time = "2025-11-13T19:58:51.155Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/68/31/c07e9c535248d10836a94e4f4e8c5a31a1beed6f169b31405b227872d4f4/ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594", size = 13171630, upload-time = "2025-11-13T19:57:54.894Z" }, - { url = "https://files.pythonhosted.org/packages/8e/5c/283c62516dca697cd604c2796d1487396b7a436b2f0ecc3fd412aca470e0/ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72", size = 13413925, upload-time = "2025-11-13T19:57:59.181Z" }, - { url = "https://files.pythonhosted.org/packages/b6/f3/aa319f4afc22cb6fcba2b9cdfc0f03bbf747e59ab7a8c5e90173857a1361/ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a", size = 12574040, upload-time = "2025-11-13T19:58:02.056Z" }, - { url = "https://files.pythonhosted.org/packages/f9/7f/cb5845fcc7c7e88ed57f58670189fc2ff517fe2134c3821e77e29fd3b0c8/ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f", size = 13009755, upload-time = "2025-11-13T19:58:05.172Z" }, - { url = "https://files.pythonhosted.org/packages/21/d2/bcbedbb6bcb9253085981730687ddc0cc7b2e18e8dc13cf4453de905d7a0/ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68", size = 12937641, upload-time = "2025-11-13T19:58:08.345Z" }, - { url = "https://files.pythonhosted.org/packages/a4/58/e25de28a572bdd60ffc6bb71fc7fd25a94ec6a076942e372437649cbb02a/ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7", size = 13610854, upload-time = "2025-11-13T19:58:11.419Z" }, - { url = "https://files.pythonhosted.org/packages/7d/24/43bb3fd23ecee9861970978ea1a7a63e12a204d319248a7e8af539984280/ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78", size = 15061088, upload-time = "2025-11-13T19:58:14.551Z" }, - { url = "https://files.pythonhosted.org/packages/23/44/a022f288d61c2f8c8645b24c364b719aee293ffc7d633a2ca4d116b9c716/ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb", size = 14734717, upload-time = "2025-11-13T19:58:17.518Z" }, - { url = "https://files.pythonhosted.org/packages/58/81/5c6ba44de7e44c91f68073e0658109d8373b0590940efe5bd7753a2585a3/ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2", size = 14028812, upload-time = "2025-11-13T19:58:20.533Z" }, - { url = "https://files.pythonhosted.org/packages/ad/ef/41a8b60f8462cb320f68615b00299ebb12660097c952c600c762078420f8/ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19", size = 13825656, upload-time = "2025-11-13T19:58:23.345Z" }, - { url = "https://files.pythonhosted.org/packages/7c/00/207e5de737fdb59b39eb1fac806904fe05681981b46d6a6db9468501062e/ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4", size = 13959922, upload-time = "2025-11-13T19:58:26.537Z" }, - { url = "https://files.pythonhosted.org/packages/bc/7e/fa1f5c2776db4be405040293618846a2dece5c70b050874c2d1f10f24776/ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1", size = 12932501, upload-time = "2025-11-13T19:58:29.822Z" }, - { url = "https://files.pythonhosted.org/packages/67/d8/d86bf784d693a764b59479a6bbdc9515ae42c340a5dc5ab1dabef847bfaa/ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151", size = 12927319, upload-time = "2025-11-13T19:58:32.923Z" }, - { url = "https://files.pythonhosted.org/packages/ac/de/ee0b304d450ae007ce0cb3e455fe24fbcaaedae4ebaad6c23831c6663651/ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465", size = 13206209, upload-time = "2025-11-13T19:58:35.952Z" }, - { url = "https://files.pythonhosted.org/packages/33/aa/193ca7e3a92d74f17d9d5771a765965d2cf42c86e6f0fd95b13969115723/ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367", size = 13953709, upload-time = "2025-11-13T19:58:39.002Z" }, - { url = "https://files.pythonhosted.org/packages/cc/f1/7119e42aa1d3bf036ffc9478885c2e248812b7de9abea4eae89163d2929d/ruff-0.14.5-py3-none-win32.whl", hash = "sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b", size = 12925808, upload-time = "2025-11-13T19:58:42.779Z" }, - { url = "https://files.pythonhosted.org/packages/3b/9d/7c0a255d21e0912114784e4a96bf62af0618e2190cae468cd82b13625ad2/ruff-0.14.5-py3-none-win_amd64.whl", hash = "sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621", size = 14331546, upload-time = "2025-11-13T19:58:45.691Z" }, - { url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331, upload-time = "2025-11-13T19:58:48.434Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/df/55/cccfca45157a2031dcbb5a462a67f7cf27f8b37d4b3b1cd7438f0f5c1df6/ruff-0.14.4.tar.gz", hash = "sha256:f459a49fe1085a749f15414ca76f61595f1a2cc8778ed7c279b6ca2e1fd19df3", size = 5587844, upload-time = "2025-11-06T22:07:45.033Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/b9/67240254166ae1eaa38dec32265e9153ac53645a6c6670ed36ad00722af8/ruff-0.14.4-py3-none-linux_armv6l.whl", hash = "sha256:e6604613ffbcf2297cd5dcba0e0ac9bd0c11dc026442dfbb614504e87c349518", size = 12606781, upload-time = "2025-11-06T22:07:01.841Z" }, + { url = "https://files.pythonhosted.org/packages/46/c8/09b3ab245d8652eafe5256ab59718641429f68681ee713ff06c5c549f156/ruff-0.14.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d99c0b52b6f0598acede45ee78288e5e9b4409d1ce7f661f0fa36d4cbeadf9a4", size = 12946765, upload-time = "2025-11-06T22:07:05.858Z" }, + { url = "https://files.pythonhosted.org/packages/14/bb/1564b000219144bf5eed2359edc94c3590dd49d510751dad26202c18a17d/ruff-0.14.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9358d490ec030f1b51d048a7fd6ead418ed0826daf6149e95e30aa67c168af33", size = 11928120, upload-time = "2025-11-06T22:07:08.023Z" }, + { url = "https://files.pythonhosted.org/packages/a3/92/d5f1770e9988cc0742fefaa351e840d9aef04ec24ae1be36f333f96d5704/ruff-0.14.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81b40d27924f1f02dfa827b9c0712a13c0e4b108421665322218fc38caf615c2", size = 12370877, upload-time = "2025-11-06T22:07:10.015Z" }, + { url = "https://files.pythonhosted.org/packages/e2/29/e9282efa55f1973d109faf839a63235575519c8ad278cc87a182a366810e/ruff-0.14.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f5e649052a294fe00818650712083cddc6cc02744afaf37202c65df9ea52efa5", size = 12408538, upload-time = "2025-11-06T22:07:13.085Z" }, + { url = "https://files.pythonhosted.org/packages/8e/01/930ed6ecfce130144b32d77d8d69f5c610e6d23e6857927150adf5d7379a/ruff-0.14.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa082a8f878deeba955531f975881828fd6afd90dfa757c2b0808aadb437136e", size = 13141942, upload-time = "2025-11-06T22:07:15.386Z" }, + { url = "https://files.pythonhosted.org/packages/6a/46/a9c89b42b231a9f487233f17a89cbef9d5acd538d9488687a02ad288fa6b/ruff-0.14.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1043c6811c2419e39011890f14d0a30470f19d47d197c4858b2787dfa698f6c8", size = 14544306, upload-time = "2025-11-06T22:07:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/78/96/9c6cf86491f2a6d52758b830b89b78c2ae61e8ca66b86bf5a20af73d20e6/ruff-0.14.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a9f3a936ac27fb7c2a93e4f4b943a662775879ac579a433291a6f69428722649", size = 14210427, upload-time = "2025-11-06T22:07:19.832Z" }, + { url = "https://files.pythonhosted.org/packages/71/f4/0666fe7769a54f63e66404e8ff698de1dcde733e12e2fd1c9c6efb689cb5/ruff-0.14.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:95643ffd209ce78bc113266b88fba3d39e0461f0cbc8b55fb92505030fb4a850", size = 13658488, upload-time = "2025-11-06T22:07:22.32Z" }, + { url = "https://files.pythonhosted.org/packages/ee/79/6ad4dda2cfd55e41ac9ed6d73ef9ab9475b1eef69f3a85957210c74ba12c/ruff-0.14.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:456daa2fa1021bc86ca857f43fe29d5d8b3f0e55e9f90c58c317c1dcc2afc7b5", size = 13354908, upload-time = "2025-11-06T22:07:24.347Z" }, + { url = "https://files.pythonhosted.org/packages/b5/60/f0b6990f740bb15c1588601d19d21bcc1bd5de4330a07222041678a8e04f/ruff-0.14.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f911bba769e4a9f51af6e70037bb72b70b45a16db5ce73e1f72aefe6f6d62132", size = 13587803, upload-time = "2025-11-06T22:07:26.327Z" }, + { url = "https://files.pythonhosted.org/packages/c9/da/eaaada586f80068728338e0ef7f29ab3e4a08a692f92eb901a4f06bbff24/ruff-0.14.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:76158a7369b3979fa878612c623a7e5430c18b2fd1c73b214945c2d06337db67", size = 12279654, upload-time = "2025-11-06T22:07:28.46Z" }, + { url = "https://files.pythonhosted.org/packages/66/d4/b1d0e82cf9bf8aed10a6d45be47b3f402730aa2c438164424783ac88c0ed/ruff-0.14.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f3b8f3b442d2b14c246e7aeca2e75915159e06a3540e2f4bed9f50d062d24469", size = 12357520, upload-time = "2025-11-06T22:07:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/04/f4/53e2b42cc82804617e5c7950b7079d79996c27e99c4652131c6a1100657f/ruff-0.14.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c62da9a06779deecf4d17ed04939ae8b31b517643b26370c3be1d26f3ef7dbde", size = 12719431, upload-time = "2025-11-06T22:07:33.831Z" }, + { url = "https://files.pythonhosted.org/packages/a2/94/80e3d74ed9a72d64e94a7b7706b1c1ebaa315ef2076fd33581f6a1cd2f95/ruff-0.14.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5a443a83a1506c684e98acb8cb55abaf3ef725078be40237463dae4463366349", size = 13464394, upload-time = "2025-11-06T22:07:35.905Z" }, + { url = "https://files.pythonhosted.org/packages/54/1a/a49f071f04c42345c793d22f6cf5e0920095e286119ee53a64a3a3004825/ruff-0.14.4-py3-none-win32.whl", hash = "sha256:643b69cb63cd996f1fc7229da726d07ac307eae442dd8974dbc7cf22c1e18fff", size = 12493429, upload-time = "2025-11-06T22:07:38.43Z" }, + { url = "https://files.pythonhosted.org/packages/bc/22/e58c43e641145a2b670328fb98bc384e20679b5774258b1e540207580266/ruff-0.14.4-py3-none-win_amd64.whl", hash = "sha256:26673da283b96fe35fa0c939bf8411abec47111644aa9f7cfbd3c573fb125d2c", size = 13635380, upload-time = "2025-11-06T22:07:40.496Z" }, + { url = "https://files.pythonhosted.org/packages/30/bd/4168a751ddbbf43e86544b4de8b5c3b7be8d7167a2a5cb977d274e04f0a1/ruff-0.14.4-py3-none-win_arm64.whl", hash = "sha256:dd09c292479596b0e6fec8cd95c65c3a6dc68e9ad17b8f2382130f87ff6a75bb", size = 12663065, upload-time = "2025-11-06T22:07:42.603Z" }, ] [[package]] @@ -5739,6 +5800,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/50/ff/26a4ee48d0b66625a4e4028a055b9f25bc9d7c7b2d17d21a45137621a50d/soundfile-0.12.1-py2.py3-none-win_amd64.whl", hash = "sha256:0d86924c00b62552b650ddd28af426e3ff2d4dc2e9047dae5b3d8452e0a49a77", size = 1009109, upload-time = "2023-02-15T15:37:29.41Z" }, ] +[[package]] +name = "soundfile" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/96/5ff33900998bad58d5381fd1acfcdac11cbea4f08fc72ac1dc25ffb13f6a/soundfile-0.12.1.tar.gz", hash = "sha256:e8e1017b2cf1dda767aef19d2fd9ee5ebe07e050d430f77a0a7c66ba08b8cdae", size = 43184, upload-time = "2023-02-15T15:37:32.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/bc/cd845c2dbb4d257c744cd58a5bcdd9f6d235ca317e7e22e49564ec88dcd9/soundfile-0.12.1-py2.py3-none-any.whl", hash = "sha256:828a79c2e75abab5359f780c81dccd4953c45a2c4cd4f05ba3e233ddf984b882", size = 24030, upload-time = "2023-02-15T15:37:16.077Z" }, + { url = "https://files.pythonhosted.org/packages/c8/73/059c84343be6509b480013bf1eeb11b96c5f9eb48deff8f83638011f6b2c/soundfile-0.12.1-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:d922be1563ce17a69582a352a86f28ed8c9f6a8bc951df63476ffc310c064bfa", size = 1213305, upload-time = "2023-02-15T15:37:18.875Z" }, + { url = "https://files.pythonhosted.org/packages/71/87/31d2b9ed58975cec081858c01afaa3c43718eb0f62b5698a876d94739ad0/soundfile-0.12.1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:bceaab5c4febb11ea0554566784bcf4bc2e3977b53946dda2b12804b4fe524a8", size = 1075977, upload-time = "2023-02-15T15:37:21.938Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bd/0602167a213d9184fc688b1086dc6d374b7ae8c33eccf169f9b50ce6568c/soundfile-0.12.1-py2.py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:2dc3685bed7187c072a46ab4ffddd38cef7de9ae5eb05c03df2ad569cf4dacbc", size = 1257765, upload-time = "2023-03-24T08:21:58.716Z" }, + { url = "https://files.pythonhosted.org/packages/c1/07/7591f4efd29e65071c3a61b53725036ea8f73366a4920a481ebddaf8d0ca/soundfile-0.12.1-py2.py3-none-manylinux_2_31_x86_64.whl", hash = "sha256:074247b771a181859d2bc1f98b5ebf6d5153d2c397b86ee9e29ba602a8dfe2a6", size = 1174746, upload-time = "2023-02-15T15:37:24.771Z" }, + { url = "https://files.pythonhosted.org/packages/03/0f/49941ed8a2d94e5b36ea94346fb1d2b22e847fede902e05be4c96f26be7d/soundfile-0.12.1-py2.py3-none-win32.whl", hash = "sha256:59dfd88c79b48f441bbf6994142a19ab1de3b9bb7c12863402c2bc621e49091a", size = 888234, upload-time = "2023-02-15T15:37:27.078Z" }, + { url = "https://files.pythonhosted.org/packages/50/ff/26a4ee48d0b66625a4e4028a055b9f25bc9d7c7b2d17d21a45137621a50d/soundfile-0.12.1-py2.py3-none-win_amd64.whl", hash = "sha256:0d86924c00b62552b650ddd28af426e3ff2d4dc2e9047dae5b3d8452e0a49a77", size = 1009109, upload-time = "2023-02-15T15:37:29.41Z" }, +] + [[package]] name = "sphinx" version = "6.1.3" From daee2ab59ef2814a244fdf61b535b1e2df6718fa Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Tue, 11 Nov 2025 17:19:45 +0100 Subject: [PATCH 09/17] fix mypy, skipping model --- .../agent_framework_declarative/_loader.py | 57 ++++++++------ .../agent_framework_declarative/_models.py | 50 +++++++++--- python/packages/declarative/pyproject.toml | 10 ++- .../declarative/get_weather_agent.py | 2 +- python/uv.lock | 78 ++++--------------- 5 files changed, 96 insertions(+), 101 deletions(-) diff --git a/python/packages/declarative/agent_framework_declarative/_loader.py b/python/packages/declarative/agent_framework_declarative/_loader.py index c561f7b706..35c0a0d68e 100644 --- a/python/packages/declarative/agent_framework_declarative/_loader.py +++ b/python/packages/declarative/agent_framework_declarative/_loader.py @@ -2,7 +2,7 @@ from collections.abc import Callable, Mapping from pathlib import Path -from typing import Any, TypedDict +from typing import Any, Literal, TypedDict import yaml from agent_framework import ( @@ -23,6 +23,8 @@ from dotenv import load_dotenv from ._models import ( + AnonymousConnection, + ApiKeyConnection, CodeInterpreterTool, FileSearchTool, FunctionTool, @@ -30,6 +32,8 @@ McpTool, ModelOptions, PromptAgent, + ReferenceConnection, + RemoteConnection, WebSearchTool, agent_schema_dispatch, ) @@ -199,24 +203,25 @@ def create_agent_from_yaml(self, yaml_str: str) -> ChatAgent: # resolve connections: client: ChatClientProtocol | None = None if prompt_agent.model and prompt_agent.model.connection: - if prompt_agent.model.connection.kind == "key": - setup_dict["api_key"] = prompt_agent.model.connection.apiKey - if prompt_agent.model.connection.endpoint: + 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(): setup_dict["endpoint"] = prompt_agent.model.connection.endpoint - elif prompt_agent.model.connection.kind == "remote": - setup_dict["endpoint"] = prompt_agent.model.connection.endpoint - elif prompt_agent.model.connection.kind == "reference": - # find the referenced connection - if not self.connections: - raise ValueError("Connections must be provided to resolve ReferenceConnection") - for name, value in self.connections.items(): - if name == prompt_agent.model.connection.name: - setup_dict[name] = value - break - else: + case ReferenceConnection(): + # find the referenced connection + if not self.connections: + raise ValueError("Connections must be provided to resolve ReferenceConnection") + for name, value in self.connections.items(): + if name == prompt_agent.model.connection.name: + setup_dict[name] = value + break + case AnonymousConnection(): + setup_dict["endpoint"] = prompt_agent.model.connection.endpoint + case _: raise ValueError(f"Referenced connection '{prompt_agent.model.connection.referenceName}' not found") - elif prompt_agent.model.connection.kind == "Anonymous": - setup_dict["endpoint"] = prompt_agent.model.connection.endpoint # check if there is a model.provider and model.apiType defined if prompt_agent.model and prompt_agent.model.provider and prompt_agent.model.apiType: # lookup the provider type in the mapping @@ -278,14 +283,16 @@ def create_agent_from_yaml(self, yaml_str: str) -> ChatAgent: func: Callable[..., Any] | None = None if self.bindings and tool_resource.bindings: for binding in tool_resource.bindings: - if binding.name in self.bindings: + if binding.name and binding.name in self.bindings: func = self.bindings[binding.name] break tools.append( AIFunction( # type: ignore - name=tool_resource.name, - description=tool_resource.description, - input_model=tool_resource.parameters.to_json_schema(), + 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, ) ) @@ -319,7 +326,9 @@ def create_agent_from_yaml(self, yaml_str: str) -> ChatAgent: ) ) case McpTool(): - approval_mode: HostedMCPSpecificApproval | str | None = None + 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" @@ -336,9 +345,9 @@ def create_agent_from_yaml(self, yaml_str: str) -> ChatAgent: } tools.append( HostedMCPTool( - name=tool_resource.name, + name=tool_resource.name, # type: ignore description=tool_resource.description, - url=tool_resource.url, + url=tool_resource.url, # type: ignore allowed_tools=tool_resource.allowedTools, approval_mode=approval_mode, ) diff --git a/python/packages/declarative/agent_framework_declarative/_models.py b/python/packages/declarative/agent_framework_declarative/_models.py index 4d90e3ab07..82af55fba3 100644 --- a/python/packages/declarative/agent_framework_declarative/_models.py +++ b/python/packages/declarative/agent_framework_declarative/_models.py @@ -1,7 +1,8 @@ # Copyright (c) Microsoft. All rights reserved. import os +import sys from collections.abc import MutableMapping -from typing import Any, TypeVar, Union +from typing import Any, Literal, TypeVar, Union from agent_framework import get_logger from agent_framework._serialization import SerializationMixin @@ -13,13 +14,23 @@ except ImportError: engine = None +if sys.version_info >= (3, 11): + from typing import overload # pragma: no cover +else: + from typing_extensions import overload # pragma: no cover logger = get_logger("agent_framework.declarative") -TEvalInput = TypeVar("TEvalInput", (str | None)) +@overload +def _try_powerfx_eval(value: None) -> None: ... -def _try_powerfx_eval(value: TEvalInput) -> TEvalInput: + +@overload +def _try_powerfx_eval(value: str) -> str: ... + + +def _try_powerfx_eval(value: str | None) -> str | None: """Check if a value refers to a environment variable and parse it if so.""" if value is None: return value @@ -207,23 +218,30 @@ def to_json_schema(self) -> dict[str, Any]: return json_schema +TConnection = TypeVar("TConnection", bound="Connection") + + class Connection(SerializationMixin): """Object representing a connection specification.""" def __init__( self, - kind: str | None = None, + kind: Literal["reference", "remote", "key", "anonymous"], authenticationMode: str | None = None, usageDescription: str | None = None, ) -> None: - self.kind = _try_powerfx_eval(kind) + self.kind = kind self.authenticationMode = _try_powerfx_eval(authenticationMode) self.usageDescription = _try_powerfx_eval(usageDescription) @classmethod def from_dict( - cls, value: MutableMapping[str, Any], /, *, dependencies: MutableMapping[str, Any] | None = None - ) -> "Connection": + cls: type[TConnection], + value: MutableMapping[str, Any], + /, + *, + dependencies: MutableMapping[str, Any] | None = None, + ) -> TConnection: """Create a Connection instance from a dictionary, dispatching to the appropriate subclass.""" # Only dispatch if we're being called on the base Connection class if cls is not Connection: @@ -255,7 +273,7 @@ class ReferenceConnection(Connection): def __init__( self, - kind: str = "reference", + kind: Literal["reference"] = "reference", authenticationMode: str | None = None, usageDescription: str | None = None, name: str | None = None, @@ -275,7 +293,7 @@ class RemoteConnection(Connection): def __init__( self, - kind: str = "remote", + kind: Literal["remote"] = "remote", authenticationMode: str | None = None, usageDescription: str | None = None, name: str | None = None, @@ -295,7 +313,7 @@ class ApiKeyConnection(Connection): def __init__( self, - kind: str = "key", + kind: Literal["key"] = "key", authenticationMode: str | None = None, usageDescription: str | None = None, endpoint: str | None = None, @@ -317,7 +335,7 @@ class AnonymousConnection(Connection): def __init__( self, - kind: str = "anonymous", + kind: Literal["anonymous"] = "anonymous", authenticationMode: str | None = None, usageDescription: str | None = None, endpoint: str | None = None, @@ -330,6 +348,14 @@ def __init__( self.endpoint = _try_powerfx_eval(endpoint) +Connections = Union[ + ReferenceConnection, + RemoteConnection, + ApiKeyConnection, + AnonymousConnection, +] + + class ModelOptions(SerializationMixin): """Object representing model options.""" @@ -369,7 +395,7 @@ def __init__( id: str | None = None, provider: str | None = None, apiType: str | None = None, - connection: Connection | None = None, + connection: Connections | None = None, options: ModelOptions | None = None, ) -> None: self.id = _try_powerfx_eval(id) diff --git a/python/packages/declarative/pyproject.toml b/python/packages/declarative/pyproject.toml index 5ef32024ae..b5efc00880 100644 --- a/python/packages/declarative/pyproject.toml +++ b/python/packages/declarative/pyproject.toml @@ -25,7 +25,10 @@ dependencies = [ "agent-framework-core", "powerfx>=0.0.31; python_version < '3.14'", "pyyaml>=6.0,<7.0", - "AgentSchema; python_version >= '3.11'", +] +[dependency-groups] +dev = [ + "types-PyYaml" ] [tool.uv] @@ -35,8 +38,6 @@ environments = [ "sys_platform == 'linux'", "sys_platform == 'win32'" ] -[tool.uv.sources] -AgentSchema = { git = "https://github.com/microsoft/AgentSchema.git", subdirectory = "runtime/python/agentschema" } [tool.uv-dynamic-versioning] @@ -76,6 +77,9 @@ 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"] diff --git a/python/samples/getting_started/declarative/get_weather_agent.py b/python/samples/getting_started/declarative/get_weather_agent.py index 7c64f64a8a..3994d8ede7 100644 --- a/python/samples/getting_started/declarative/get_weather_agent.py +++ b/python/samples/getting_started/declarative/get_weather_agent.py @@ -30,7 +30,7 @@ async def main(): bindings={"get_weather": get_weather}, ) # create the agent from the yaml - agent = agent_factory.create_agent_from_yaml(yaml_str, use_maml=True) + agent = agent_factory.create_agent_from_yaml(yaml_str) # use the agent response = await agent.run("What's the weather in Amsterdam, in celsius?") print("Agent response:", response.text) diff --git a/python/uv.lock b/python/uv.lock index 1596df519c..b3a300971a 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -344,19 +344,25 @@ version = "1.0.0b251028" source = { editable = "packages/declarative" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "agentschema", 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 = "powerfx", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, { name = "pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] +[package.dev-dependencies] +dev = [ + { name = "types-pyyaml", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "agentschema", marker = "python_full_version >= '3.11'", git = "https://github.com/microsoft/AgentSchema.git?subdirectory=runtime%2Fpython%2Fagentschema" }, { name = "powerfx", marker = "python_full_version < '3.14'", specifier = ">=0.0.31" }, { name = "pyyaml", specifier = ">=6.0,<7.0" }, ] +[package.metadata.requires-dev] +dev = [{ name = "types-pyyaml" }] + [[package]] name = "agent-framework-devui" version = "1.0.0b251114" @@ -586,19 +592,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/63/3e48da56d5121ddcefef8645ad5a3446b0974154111a14bf75ea2b5b3cc3/agentops-0.4.21-py3-none-any.whl", hash = "sha256:93b098ea77bc5f64dcae5031a8292531cb446d9d66e6c7ef2f21a66d4e4fb2f0", size = 309579, upload-time = "2025-08-29T06:36:53.855Z" }, ] -[[package]] -name = "agentschema" -version = "0.1.0" -source = { git = "https://github.com/microsoft/AgentSchema.git?subdirectory=runtime%2Fpython%2Fagentschema#354032654c853d95fe7a5f24fa3d025f93e60b8c" } -dependencies = [ - { name = "aiofiles", 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 = "black", 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 = "click", 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-dotenv", 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 = "pyyaml", 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 = "ruff", 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')" }, -] - [[package]] name = "aiofiles" version = "25.1.0" @@ -1028,43 +1021,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] -[[package]] -name = "black" -version = "25.11.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click", 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 = "mypy-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')" }, - { name = "packaging", 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 = "pathspec", 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 = "platformdirs", 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 = "pytokens", 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/8c/ad/33adf4708633d047950ff2dfdea2e215d84ac50ef95aff14a614e4b6e9b2/black-25.11.0.tar.gz", hash = "sha256:9a323ac32f5dc75ce7470501b887250be5005a01602e931a15e45593f70f6e08", size = 655669, upload-time = "2025-11-10T01:53:50.558Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/d2/6caccbc96f9311e8ec3378c296d4f4809429c43a6cd2394e3c390e86816d/black-25.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ec311e22458eec32a807f029b2646f661e6859c3f61bc6d9ffb67958779f392e", size = 1743501, upload-time = "2025-11-10T01:59:06.202Z" }, - { url = "https://files.pythonhosted.org/packages/69/35/b986d57828b3f3dccbf922e2864223197ba32e74c5004264b1c62bc9f04d/black-25.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1032639c90208c15711334d681de2e24821af0575573db2810b0763bcd62e0f0", size = 1597308, upload-time = "2025-11-10T01:57:58.633Z" }, - { url = "https://files.pythonhosted.org/packages/39/8e/8b58ef4b37073f52b64a7b2dd8c9a96c84f45d6f47d878d0aa557e9a2d35/black-25.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0f7c461df55cf32929b002335883946a4893d759f2df343389c4396f3b6b37", size = 1656194, upload-time = "2025-11-10T01:57:10.909Z" }, - { url = "https://files.pythonhosted.org/packages/8d/30/9c2267a7955ecc545306534ab88923769a979ac20a27cf618d370091e5dd/black-25.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:f9786c24d8e9bd5f20dc7a7f0cdd742644656987f6ea6947629306f937726c03", size = 1347996, upload-time = "2025-11-10T01:57:22.391Z" }, - { url = "https://files.pythonhosted.org/packages/c4/62/d304786b75ab0c530b833a89ce7d997924579fb7484ecd9266394903e394/black-25.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:895571922a35434a9d8ca67ef926da6bc9ad464522a5fe0db99b394ef1c0675a", size = 1727891, upload-time = "2025-11-10T02:01:40.507Z" }, - { url = "https://files.pythonhosted.org/packages/82/5d/ffe8a006aa522c9e3f430e7b93568a7b2163f4b3f16e8feb6d8c3552761a/black-25.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb4f4b65d717062191bdec8e4a442539a8ea065e6af1c4f4d36f0cdb5f71e170", size = 1581875, upload-time = "2025-11-10T01:57:51.192Z" }, - { url = "https://files.pythonhosted.org/packages/cb/c8/7c8bda3108d0bb57387ac41b4abb5c08782b26da9f9c4421ef6694dac01a/black-25.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d81a44cbc7e4f73a9d6ae449ec2317ad81512d1e7dce7d57f6333fd6259737bc", size = 1642716, upload-time = "2025-11-10T01:56:51.589Z" }, - { url = "https://files.pythonhosted.org/packages/34/b9/f17dea34eecb7cc2609a89627d480fb6caea7b86190708eaa7eb15ed25e7/black-25.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:7eebd4744dfe92ef1ee349dc532defbf012a88b087bb7ddd688ff59a447b080e", size = 1352904, upload-time = "2025-11-10T01:59:26.252Z" }, - { url = "https://files.pythonhosted.org/packages/7f/12/5c35e600b515f35ffd737da7febdb2ab66bb8c24d88560d5e3ef3d28c3fd/black-25.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:80e7486ad3535636657aa180ad32a7d67d7c273a80e12f1b4bfa0823d54e8fac", size = 1772831, upload-time = "2025-11-10T02:03:47Z" }, - { url = "https://files.pythonhosted.org/packages/1a/75/b3896bec5a2bb9ed2f989a970ea40e7062f8936f95425879bbe162746fe5/black-25.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6cced12b747c4c76bc09b4db057c319d8545307266f41aaee665540bc0e04e96", size = 1608520, upload-time = "2025-11-10T01:58:46.895Z" }, - { url = "https://files.pythonhosted.org/packages/f3/b5/2bfc18330eddbcfb5aab8d2d720663cd410f51b2ed01375f5be3751595b0/black-25.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb2d54a39e0ef021d6c5eef442e10fd71fcb491be6413d083a320ee768329dd", size = 1682719, upload-time = "2025-11-10T01:56:55.24Z" }, - { url = "https://files.pythonhosted.org/packages/96/fb/f7dc2793a22cdf74a72114b5ed77fe3349a2e09ef34565857a2f917abdf2/black-25.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae263af2f496940438e5be1a0c1020e13b09154f3af4df0835ea7f9fe7bfa409", size = 1362684, upload-time = "2025-11-10T01:57:07.639Z" }, - { url = "https://files.pythonhosted.org/packages/ad/47/3378d6a2ddefe18553d1115e36aea98f4a90de53b6a3017ed861ba1bd3bc/black-25.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a1d40348b6621cc20d3d7530a5b8d67e9714906dfd7346338249ad9c6cedf2b", size = 1772446, upload-time = "2025-11-10T02:02:16.181Z" }, - { url = "https://files.pythonhosted.org/packages/ba/4b/0f00bfb3d1f7e05e25bfc7c363f54dc523bb6ba502f98f4ad3acf01ab2e4/black-25.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51c65d7d60bb25429ea2bf0731c32b2a2442eb4bd3b2afcb47830f0b13e58bfd", size = 1607983, upload-time = "2025-11-10T02:02:52.502Z" }, - { url = "https://files.pythonhosted.org/packages/99/fe/49b0768f8c9ae57eb74cc10a1f87b4c70453551d8ad498959721cc345cb7/black-25.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:936c4dd07669269f40b497440159a221ee435e3fddcf668e0c05244a9be71993", size = 1682481, upload-time = "2025-11-10T01:57:12.35Z" }, - { url = "https://files.pythonhosted.org/packages/55/17/7e10ff1267bfa950cc16f0a411d457cdff79678fbb77a6c73b73a5317904/black-25.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:f42c0ea7f59994490f4dccd64e6b2dd49ac57c7c84f38b8faab50f8759db245c", size = 1363869, upload-time = "2025-11-10T01:58:24.608Z" }, - { url = "https://files.pythonhosted.org/packages/67/c0/cc865ce594d09e4cd4dfca5e11994ebb51604328489f3ca3ae7bb38a7db5/black-25.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:35690a383f22dd3e468c85dc4b915217f87667ad9cce781d7b42678ce63c4170", size = 1771358, upload-time = "2025-11-10T02:03:33.331Z" }, - { url = "https://files.pythonhosted.org/packages/37/77/4297114d9e2fd2fc8ab0ab87192643cd49409eb059e2940391e7d2340e57/black-25.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dae49ef7369c6caa1a1833fd5efb7c3024bb7e4499bf64833f65ad27791b1545", size = 1612902, upload-time = "2025-11-10T01:59:33.382Z" }, - { url = "https://files.pythonhosted.org/packages/de/63/d45ef97ada84111e330b2b2d45e1dd163e90bd116f00ac55927fb6bf8adb/black-25.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bd4a22a0b37401c8e492e994bce79e614f91b14d9ea911f44f36e262195fdda", size = 1680571, upload-time = "2025-11-10T01:57:04.239Z" }, - { url = "https://files.pythonhosted.org/packages/ff/4b/5604710d61cdff613584028b4cb4607e56e148801ed9b38ee7970799dab6/black-25.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:aa211411e94fdf86519996b7f5f05e71ba34835d8f0c0f03c00a26271da02664", size = 1382599, upload-time = "2025-11-10T01:57:57.427Z" }, - { url = "https://files.pythonhosted.org/packages/00/5d/aed32636ed30a6e7f9efd6ad14e2a0b0d687ae7c8c7ec4e4a557174b895c/black-25.11.0-py3-none-any.whl", hash = "sha256:e3f562da087791e96cefcd9dda058380a442ab322a02e222add53736451f604b", size = 204918, upload-time = "2025-11-10T01:53:48.917Z" }, -] - [[package]] name = "blinker" version = "1.9.0" @@ -4959,15 +4915,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cd/f1/bfb6811df4745f92f14c47a29e50e89a36b1533130fcc56452d4660bd2d6/pythonnet-3.0.5-py3-none-any.whl", hash = "sha256:f6702d694d5d5b163c9f3f5cc34e0bed8d6857150237fae411fefb883a656d20", size = 297506, upload-time = "2024-12-13T08:30:40.661Z" }, ] -[[package]] -name = "pytokens" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/8d/a762be14dae1c3bf280202ba3172020b2b0b4c537f94427435f19c413b72/pytokens-0.3.0.tar.gz", hash = "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a", size = 17644, upload-time = "2025-11-05T13:36:35.34Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/25/d9db8be44e205a124f6c98bc0324b2bb149b7431c53877fc6d1038dddaf5/pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3", size = 12195, upload-time = "2025-11-05T13:36:33.183Z" }, -] - [[package]] name = "pytz" version = "2025.2" @@ -6227,6 +6174,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/dd/5cbf31f402f1cc0ab087c94d4669cfa55bd1e818688b910631e131d74e75/typer_slim-0.20.0-py3-none-any.whl", hash = "sha256:f42a9b7571a12b97dddf364745d29f12221865acef7a2680065f9bb29c7dc89d", size = 47087, upload-time = "2025-10-20T17:03:44.546Z" }, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20250915" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522, upload-time = "2025-09-15T03:01:00.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" }, +] + [[package]] name = "types-requests" version = "2.32.4.20250913" From 70b3326a2f27cf64048a54f7c17e206ae9167de4 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 13 Nov 2025 13:54:21 +0100 Subject: [PATCH 10/17] updated lock --- python/uv.lock | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/python/uv.lock b/python/uv.lock index b3a300971a..6c607fe36f 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -2447,9 +2447,9 @@ dependencies = [ { name = "typer-slim", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b8/63/eeea214a6b456d8e91ac2ea73ebb83da3af9aa64716dfb6e28dd9b2e6223/huggingface_hub-1.1.2.tar.gz", hash = "sha256:7bdafc432dc12fa1f15211bdfa689a02531d2a47a3cc0d74935f5726cdbcab8e", size = 606173, upload-time = "2025-11-06T10:04:38.398Z" } +sdist = { url = "https://files.pythonhosted.org/packages/44/8a/3cba668d9cd1b4e3eb6c1c3ff7bf0f74a7809bdbb5c327bcdbdbac802d23/huggingface_hub-1.1.4.tar.gz", hash = "sha256:a7424a766fffa1a11e4c1ac2040a1557e2101f86050fdf06627e7b74cc9d2ad6", size = 606842, upload-time = "2025-11-13T10:51:57.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/21/e15d90fd09b56938502a0348d566f1915f9789c5bb6c00c1402dc7259b6e/huggingface_hub-1.1.2-py3-none-any.whl", hash = "sha256:dfcfa84a043466fac60573c3e4af475490a7b0d7375b22e3817706d6659f61f7", size = 514955, upload-time = "2025-11-06T10:04:36.674Z" }, + { url = "https://files.pythonhosted.org/packages/33/3f/969137c9d9428ed8bf171d27604243dd950a47cac82414826e2aebbc0a4c/huggingface_hub-1.1.4-py3-none-any.whl", hash = "sha256:867799fbd2ef338b7f8b03d038d9c0e09415dfe45bb2893b48a510d1d746daa5", size = 515580, upload-time = "2025-11-13T10:51:55.742Z" }, ] [[package]] @@ -4246,6 +4246,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/05/0c/8b6b20b0be71725e6e8a32dcd460cdbf62fe6df9bc656a650150dc98fedd/posthog-7.0.1-py3-none-any.whl", hash = "sha256:efe212d8d88a9ba80a20c588eab4baf4b1a5e90e40b551160a5603bb21e96904", size = 145234, upload-time = "2025-11-15T12:44:21.247Z" }, ] +[[package]] +name = "powerfx" +version = "0.0.31" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pythonnet", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/1d/40228886242df10c10ed69faf27e973d020c586aa723a51afbe48542d535/powerfx-0.0.31.tar.gz", hash = "sha256:fa9637f315d71163dd900d16f97fce562d550049713d2fc358f8d446bb23906f", size = 3235618, upload-time = "2025-09-16T15:10:13.159Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/45/fdc98dc8a3e38a3cde464e18624a4851785bf7cc63f207d04279e0a1db4f/powerfx-0.0.31-py3-none-any.whl", hash = "sha256:616dcff4950624d3c63dd72c01daea60b0838e217b0c5533dd2c40677444a340", size = 3481524, upload-time = "2025-09-16T15:10:10.393Z" }, +] + [[package]] name = "pre-commit" version = "4.4.0" From 481b9a1810bbe924469b962c4049e47d5bb51407 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Thu, 13 Nov 2025 14:34:42 +0100 Subject: [PATCH 11/17] fixed declarative tests and renamed some other test files --- python/packages/ag-ui/tests/__init__.py | 1 - .../{test_models.py => test_func_models.py} | 0 python/packages/chatkit/tests/__init__.py | 1 - .../tests/test_declarative_loader.py | 42 ++++++++++--------- .../tests/test_declarative_models.py | 7 ---- ...{test_models.py => test_purview_models.py} | 0 6 files changed, 22 insertions(+), 29 deletions(-) delete mode 100644 python/packages/ag-ui/tests/__init__.py rename python/packages/azurefunctions/tests/{test_models.py => test_func_models.py} (100%) delete mode 100644 python/packages/chatkit/tests/__init__.py rename python/packages/purview/tests/{test_models.py => test_purview_models.py} (100%) diff --git a/python/packages/ag-ui/tests/__init__.py b/python/packages/ag-ui/tests/__init__.py deleted file mode 100644 index 2a50eae894..0000000000 --- a/python/packages/ag-ui/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. diff --git a/python/packages/azurefunctions/tests/test_models.py b/python/packages/azurefunctions/tests/test_func_models.py similarity index 100% rename from python/packages/azurefunctions/tests/test_models.py rename to python/packages/azurefunctions/tests/test_func_models.py diff --git a/python/packages/chatkit/tests/__init__.py b/python/packages/chatkit/tests/__init__.py deleted file mode 100644 index 2a50eae894..0000000000 --- a/python/packages/chatkit/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. diff --git a/python/packages/declarative/tests/test_declarative_loader.py b/python/packages/declarative/tests/test_declarative_loader.py index ff66395fe2..daf4ab06f8 100644 --- a/python/packages/declarative/tests/test_declarative_loader.py +++ b/python/packages/declarative/tests/test_declarative_loader.py @@ -2,10 +2,11 @@ import sys from pathlib import Path +from typing import Any import pytest +import yaml -from agent_framework_declarative._loader import load_yaml_spec from agent_framework_declarative._models import ( AgentDefinition, AgentManifest, @@ -33,6 +34,7 @@ Resource, ToolResource, WebSearchTool, + agent_schema_dispatch, ) pytestmark = pytest.mark.skipif(sys.version_info >= (3, 14), reason="Skipping on Python 3.14+") @@ -286,9 +288,9 @@ ), ], ) -def test_load_yaml_spec_all_types(yaml_content, expected_type, expected_attributes): - """Test that load_yaml_spec correctly loads all MAML object types.""" - result = load_yaml_spec(yaml_content) +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__}" @@ -301,17 +303,17 @@ def test_load_yaml_spec_all_types(yaml_content, expected_type, expected_attribut ) -def test_load_yaml_spec_unknown_kind(): - """Test that load_yaml_spec returns None for unknown kind.""" +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 = load_yaml_spec(yaml_content) + result = agent_schema_dispatch(yaml.safe_load(yaml_content)) assert result is None -def test_load_yaml_spec_complex_agent_manifest(): +def test_agent_schema_dispatch_complex_agent_manifest(): """Test loading a complex agent manifest with nested objects.""" yaml_content = """ name: complex-manifest @@ -338,7 +340,7 @@ def test_load_yaml_spec_complex_agent_manifest(): name: tool1 id: search """ - result = load_yaml_spec(yaml_content) + result = agent_schema_dispatch(yaml.safe_load(yaml_content)) assert isinstance(result, AgentManifest) assert result.name == "complex-manifest" @@ -350,7 +352,7 @@ def test_load_yaml_spec_complex_agent_manifest(): assert isinstance(result.resources[1], ToolResource) -def test_load_yaml_spec_prompt_agent_with_tools(): +def test_agent_schema_dispatch_prompt_agent_with_tools(): """Test loading a prompt agent with multiple tools.""" yaml_content = """ kind: Prompt @@ -369,7 +371,7 @@ def test_load_yaml_spec_prompt_agent_with_tools(): name: code description: Execute code """ - result = load_yaml_spec(yaml_content) + result = agent_schema_dispatch(yaml.safe_load(yaml_content)) assert isinstance(result, PromptAgent) assert result.name == "multi-tool-agent" @@ -380,20 +382,20 @@ def test_load_yaml_spec_prompt_agent_with_tools(): assert result.tools[2].kind == "code_interpreter" -def test_load_yaml_spec_model_resource(): +def test_agent_schema_dispatch_model_resource(): """Test loading a model resource.""" yaml_content = """ kind: Model name: my-model id: gpt-4 """ - result = load_yaml_spec(yaml_content) + result = agent_schema_dispatch(yaml.safe_load(yaml_content)) assert isinstance(result, ModelResource) assert result.id == "gpt-4" -def test_load_yaml_spec_property_schema_with_nested_properties(): +def test_agent_schema_dispatch_property_schema_with_nested_properties(): """Test loading a property schema with nested properties.""" yaml_content = """ kind: property_schema @@ -416,7 +418,7 @@ def test_load_yaml_spec_property_schema_with_nested_properties(): name: tags description: User tags """ - result = load_yaml_spec(yaml_content) + result = agent_schema_dispatch(yaml.safe_load(yaml_content)) assert isinstance(result, PropertySchema) assert result.strict is True @@ -427,7 +429,7 @@ def test_load_yaml_spec_property_schema_with_nested_properties(): assert result.properties[2].kind == "array" -def _get_agent_sample_yaml_files(): +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 @@ -445,10 +447,10 @@ def _get_agent_sample_yaml_files(): _get_agent_sample_yaml_files(), ids=lambda x: x[0].name if isinstance(x, tuple) else str(x), ) -def test_load_yaml_spec_agent_samples(yaml_file: Path, agent_samples_dir: Path): - """Test that load_yaml_spec successfully loads a YAML file from agent-samples directory.""" +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 = load_yaml_spec(content) + 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"load_yaml_spec returned None for {yaml_file.relative_to(agent_samples_dir)}" + assert result is not None, f"agent_schema_dispatch returned None for {yaml_file.relative_to(agent_samples_dir)}" diff --git a/python/packages/declarative/tests/test_declarative_models.py b/python/packages/declarative/tests/test_declarative_models.py index b4779c6d8e..dc13b3a642 100644 --- a/python/packages/declarative/tests/test_declarative_models.py +++ b/python/packages/declarative/tests/test_declarative_models.py @@ -941,13 +941,6 @@ def test_logical_operators_with_strings(self): # ! operator (alternative syntax) - returns bool assert _try_powerfx_eval('=!("a" = "b")') is True - def test_invalid_expressions_return_none(self): - """Test that invalid PowerFx expressions return None.""" - # Syntax errors should return None - assert _try_powerfx_eval("=invalid syntax !!!") is None - assert _try_powerfx_eval("=Env.NONEXISTENT_VAR") is None - assert _try_powerfx_eval("=1 / 0") is None # Division by zero - def test_parentheses_for_precedence(self): """Test using parentheses to control operator precedence.""" from decimal import Decimal diff --git a/python/packages/purview/tests/test_models.py b/python/packages/purview/tests/test_purview_models.py similarity index 100% rename from python/packages/purview/tests/test_models.py rename to python/packages/purview/tests/test_purview_models.py From 50e80a9f4f2eb8a04673c76f5e0e68f25f64ddc6 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Fri, 14 Nov 2025 10:01:23 +0100 Subject: [PATCH 12/17] refined loader --- agent-samples/README.md | 2 +- .../agent_framework/declarative/__init__.py | 2 +- .../agent_framework/declarative/__init__.pyi | 16 +- .../agent_framework_declarative/__init__.py | 10 +- .../agent_framework_declarative/_loader.py | 388 ++++++++++-------- 5 files changed, 227 insertions(+), 191 deletions(-) diff --git a/agent-samples/README.md b/agent-samples/README.md index 58b5696464..0ee940f3a0 100644 --- a/agent-samples/README.md +++ b/agent-samples/README.md @@ -1,3 +1,3 @@ # Declarative Agents -This folder contains sample agent definitions than be ran using the [Declarative Agents]() demo. +This folder contains sample agent definitions than be ran using the declarative agent support, for python see the [declarative agent python sample folder](../python/samples/getting_started/declarative/). diff --git a/python/packages/core/agent_framework/declarative/__init__.py b/python/packages/core/agent_framework/declarative/__init__.py index 8486cdee6c..d6002b9b0a 100644 --- a/python/packages/core/agent_framework/declarative/__init__.py +++ b/python/packages/core/agent_framework/declarative/__init__.py @@ -5,7 +5,7 @@ IMPORT_PATH = "agent_framework_declarative" PACKAGE_NAME = "agent-framework-declarative" -_IMPORTS = ["__version__", "AgentFactory", "DeclarativeLoaderError", "ProviderLookupError"] +_IMPORTS = ["__version__", "AgentFactory", "DeclarativeLoaderError", "ProviderLookupError", "ProviderTypeMapping"] def __getattr__(name: str) -> Any: diff --git a/python/packages/core/agent_framework/declarative/__init__.pyi b/python/packages/core/agent_framework/declarative/__init__.pyi index ea27e02760..0e19cc8687 100644 --- a/python/packages/core/agent_framework/declarative/__init__.pyi +++ b/python/packages/core/agent_framework/declarative/__init__.pyi @@ -1,5 +1,17 @@ # Copyright (c) Microsoft. All rights reserved. -from agent_framework_declarative import AgentFactory, DeclarativeLoaderError, ProviderLookupError, __version__ +from agent_framework_declarative import ( + AgentFactory, + DeclarativeLoaderError, + ProviderLookupError, + ProviderTypeMapping, + __version__, +) -__all__ = ["AgentFactory", "DeclarativeLoaderError", "ProviderLookupError", "__version__"] +__all__ = [ + "AgentFactory", + "DeclarativeLoaderError", + "ProviderLookupError", + "ProviderTypeMapping", + "__version__", +] diff --git a/python/packages/declarative/agent_framework_declarative/__init__.py b/python/packages/declarative/agent_framework_declarative/__init__.py index 6f975ef9c2..bfc1bdffdc 100644 --- a/python/packages/declarative/agent_framework_declarative/__init__.py +++ b/python/packages/declarative/agent_framework_declarative/__init__.py @@ -1,12 +1,12 @@ # Copyright (c) Microsoft. All rights reserved. -import importlib +from importlib import metadata -from ._loader import AgentFactory, DeclarativeLoaderError, ProviderLookupError +from ._loader import AgentFactory, DeclarativeLoaderError, ProviderLookupError, ProviderTypeMapping try: - __version__ = importlib.metadata.version(__name__) -except importlib.metadata.PackageNotFoundError: + __version__ = metadata.version(__name__) +except metadata.PackageNotFoundError: __version__ = "0.0.0" # Fallback for development mode -__all__ = ["AgentFactory", "DeclarativeLoaderError", "ProviderLookupError", "__version__"] +__all__ = ["AgentFactory", "DeclarativeLoaderError", "ProviderLookupError", "ProviderTypeMapping", "__version__"] diff --git a/python/packages/declarative/agent_framework_declarative/_loader.py b/python/packages/declarative/agent_framework_declarative/_loader.py index 35c0a0d68e..4aec426d7a 100644 --- a/python/packages/declarative/agent_framework_declarative/_loader.py +++ b/python/packages/declarative/agent_framework_declarative/_loader.py @@ -30,10 +30,12 @@ FunctionTool, McpServerToolSpecifyApprovalMode, McpTool, + Model, ModelOptions, PromptAgent, ReferenceConnection, RemoteConnection, + Tool, WebSearchTool, agent_schema_dispatch, ) @@ -81,6 +83,11 @@ class ProviderTypeMapping(TypedDict, total=True): "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", @@ -104,11 +111,13 @@ class ProviderLookupError(DeclarativeLoaderError): 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. @@ -120,8 +129,7 @@ def __init__( 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 constructors. - env_file: An optional path to a .env file to load environment variables from. + 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: @@ -135,28 +143,44 @@ def __init__( }, ... } + + 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 MAML object from a YAML file path asynchronously. + """Create a ChatAgent from a YAML file path. - This method wraps the synchronous load_yaml_spec function to provide - asynchronous behavior. + 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 MAML object + yaml_path: Path to the YAML file representation of a AgentSchema object + Returns: - The object instance created from the YAML file. + 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. """ @@ -169,223 +193,223 @@ def create_agent_from_yaml_path(self, yaml_path: str | Path) -> ChatAgent: return self.create_agent_from_yaml(yaml_str) def create_agent_from_yaml(self, yaml_str: str) -> ChatAgent: - """Create a MAML object from a YAML string asynchronously. - - This method wraps the synchronous load_yaml_spec function to provide - asynchronous behavior. + """Create a ChatAgent from a YAML string. This method does the following things: - 1. Loads the YAML string into a MAML object using load_yaml_spec. + 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 MAML object + yaml_str: YAML string representation of a AgentSchema object Returns: - The object instance created from the YAML string. + 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 PromptAgent kind is supported for agent creation") + 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) - # resolve connections: - client: ChatClientProtocol | None = None - if prompt_agent.model and prompt_agent.model.connection: + + # 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(): + case RemoteConnection() | AnonymousConnection(): setup_dict["endpoint"] = prompt_agent.model.connection.endpoint case ReferenceConnection(): - # find the referenced connection if not self.connections: raise ValueError("Connections must be provided to resolve ReferenceConnection") - for name, value in self.connections.items(): - if name == prompt_agent.model.connection.name: - setup_dict[name] = value - break - case AnonymousConnection(): - setup_dict["endpoint"] = prompt_agent.model.connection.endpoint - case _: - raise ValueError(f"Referenced connection '{prompt_agent.model.connection.referenceName}' not found") - # check if there is a model.provider and model.apiType defined - if prompt_agent.model and prompt_agent.model.provider and prompt_agent.model.apiType: - # lookup the provider type in the mapping - class_lookup = f"{prompt_agent.model.provider}.{prompt_agent.model.apiType}" - if class_lookup in PROVIDER_TYPE_OBJECT_MAPPING: - mapping = self._retrieve_provider_configuration(class_lookup) - 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 - client = agent_class(**setup_dict) - else: - raise ValueError("Unsupported model provider or apiType in PromptAgent") - if not client and prompt_agent.model and prompt_agent.model.id: - # assume AzureAIAgentClient - mapping = self._retrieve_provider_configuration("AzureAIAgentClient") - 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 - client = agent_class(**setup_dict) - elif not client: - # get a ChatClientProtocol supplied - if not self.chat_client: - raise ValueError("ChatClient must be provided to create agent from PromptAgent") - client = self.chat_client - # Step 2: Parse the other properties, including tools, options and response_format - # Options - chat_options: dict[str, Any] = {} - if prompt_agent.model and (options := prompt_agent.model.options) and isinstance(options, ModelOptions): - 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 - # Tools - tools: list[ToolProtocol] = [] - if prompt_agent.tools: - for tool_resource in prompt_agent.tools: - 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 binding.name in self.bindings: - func = self.bindings[binding.name] - break - tools.append( - 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(): - tools.append( - 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 - tools.append( - 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(): - tools.append( - 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): - if tool_resource.approvalMode.alwaysRequireApprovalTools: - approval_mode = { - "always_require_approval": tool_resource.approvalMode.alwaysRequireApprovalTools - } - else: - approval_mode = { - "never_require_approval": tool_resource.approvalMode.neverRequireApprovalTools - } - tools.append( - 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, - ) + # find the referenced connection + if 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." ) - case _: - raise ValueError(f"Unsupported tool kind: {tool_resource.kind}") - - # response format - if prompt_agent.outputSchema: - pydantic_model = _create_model_from_json_schema("agent", prompt_agent.outputSchema.to_json_schema()) - chat_options["response_format"] = pydantic_model - - # Step 3: Create the agent instance - return ChatAgent( - chat_client=client, - name=prompt_agent.name, - description=prompt_agent.description, - instructions=prompt_agent.instructions, - tools=tools, - **chat_options, - ) - - def _retrieve_provider_configuration(self, class_lookup: str) -> ProviderTypeMapping: - """Retrieve the provider configuration for a given class lookup. - This method will first attempt to find the class lookup in the additional mappings - provided to the AgentFactory. If not found there, it will look in the default - PROVIDER_TYPE_OBJECT_MAPPING. + # 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) + + 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): + if tool_resource.approvalMode.alwaysRequireApprovalTools: + approval_mode = { + "always_require_approval": tool_resource.approvalMode.alwaysRequireApprovalTools + } + else: + approval_mode = { + "never_require_approval": tool_resource.approvalMode.neverRequireApprovalTools + } + 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: - class_lookup: The class lookup string in the format 'Provider.ApiType' + 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. + 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: From c7e7c6fd316627333be1817c700312f7e6ae78e1 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Fri, 14 Nov 2025 10:05:35 +0100 Subject: [PATCH 13/17] updated lock --- python/uv.lock | 70 +++++++++++++++++++------------------------------- 1 file changed, 26 insertions(+), 44 deletions(-) diff --git a/python/uv.lock b/python/uv.lock index 6c607fe36f..2bada74812 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1062,9 +1062,9 @@ wheels = [ name = "cachetools" version = "6.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/7e/b975b5814bd36faf009faebe22c1072a1fa1168db34d285ef0ba071ad78c/cachetools-6.2.1.tar.gz", hash = "sha256:3f391e4bd8f8bf0931169baf7456cc822705f4e2a31f840d218f445b9a854201", size = 31325, upload-time = "2025-10-12T14:55:30.139Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/44/ca1675be2a83aeee1886ab745b28cda92093066590233cc501890eb8417a/cachetools-6.2.2.tar.gz", hash = "sha256:8e6d266b25e539df852251cfd6f990b4bc3a141db73b939058d809ebd2590fc6", size = 31571, upload-time = "2025-11-13T17:42:51.465Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/c5/1e741d26306c42e2bf6ab740b2202872727e0f606033c9dd713f8b93f5a8/cachetools-6.2.1-py3-none-any.whl", hash = "sha256:09868944b6dde876dfd44e1d47e18484541eaf12f26f29b7af91b26cc892d701", size = 11280, upload-time = "2025-10-12T14:55:28.382Z" }, + { url = "https://files.pythonhosted.org/packages/e6/46/eb6eca305c77a4489affe1c5d8f4cae82f285d9addd8de4ec084a7184221/cachetools-6.2.2-py3-none-any.whl", hash = "sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace", size = 11503, upload-time = "2025-11-13T17:42:50.232Z" }, ] [[package]] @@ -3137,9 +3137,9 @@ dependencies = [ { name = "qdrant-client", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "sqlalchemy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/99/02/b6c3bba83b4bb6450e6c8a07e4419b24644007588f5ef427b680addbd30f/mem0ai-1.0.0.tar.gz", hash = "sha256:8a891502e6547436adb526a59acf091cacaa689e182e186f4dd8baf185d75224", size = 177780, upload-time = "2025-10-16T10:36:23.871Z" } +sdist = { url = "https://files.pythonhosted.org/packages/39/cd/f9047cd45952af08da8084c2297f8aad780f9ac8558631fc64b3ed235b28/mem0ai-1.0.1.tar.gz", hash = "sha256:53be77f479387e6c07508096eb6c0688150b31152613bdcf6c281246b000b14d", size = 182296, upload-time = "2025-11-13T22:32:13.658Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/49/eed6e2a77bf90e37da25c9a336af6a6129b0baae76551409ee995f0a1f0c/mem0ai-1.0.0-py3-none-any.whl", hash = "sha256:107fd2990613eba34880ca6578e6cdd4a8158fd35f5b80be031b6e2b5a66a1f1", size = 268141, upload-time = "2025-10-16T10:36:21.63Z" }, + { url = "https://files.pythonhosted.org/packages/81/42/120d6db33e190ef09d69428ddd2eaaa87e10f4c8243af788f5fc524748c9/mem0ai-1.0.1-py3-none-any.whl", hash = "sha256:a8eeca9688e87f175af53d463b4a3b2d552984c81e29bc656c847dc04eaf6f75", size = 275351, upload-time = "2025-11-13T22:32:11.839Z" }, ] [[package]] @@ -3673,9 +3673,9 @@ dependencies = [ { name = "types-requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/00/b3/c25c3b3084c113ffbd161c37ed7c02e0cc1296eb2f2d582d85c461f60c7a/openai_agents-0.5.0.tar.gz", hash = "sha256:776dde4025442164e3e860ff5b239b5c0ebc30f9445b0d75295c385a8ca1f696", size = 1958702, upload-time = "2025-11-05T05:28:37.456Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/1f/322595f9a7ffd48afe2449bb92090eb893ba6ae4475e3ee549f64566a3a1/openai_agents-0.5.1.tar.gz", hash = "sha256:e193cd3a1b0d4f9a3f3fa9c4011c0b1f8876fa5f38bde4ae41d6a834a5791124", size = 1990900, upload-time = "2025-11-13T17:59:36.173Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/f5/c43a84a64aa3328c628cc19365dc514ce02abf31e31c861ad489d6d3075b/openai_agents-0.5.0-py3-none-any.whl", hash = "sha256:5ef062273815de197315ec760f571625d0f2766ceb83ab189ba6cdd9b26a10e9", size = 223272, upload-time = "2025-11-05T05:28:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/10/ca/352a7167ac040f9e7d6765081f4021811914724303d1417525d50942e15e/openai_agents-0.5.1-py3-none-any.whl", hash = "sha256:7077c47d8e4230d788a18922df7cd69f13c3328a57744156195da4921c08c835", size = 231786, upload-time = "2025-11-13T17:59:32.691Z" }, ] [[package]] @@ -5374,26 +5374,26 @@ wheels = [ name = "ruff" version = "0.14.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/55/cccfca45157a2031dcbb5a462a67f7cf27f8b37d4b3b1cd7438f0f5c1df6/ruff-0.14.4.tar.gz", hash = "sha256:f459a49fe1085a749f15414ca76f61595f1a2cc8778ed7c279b6ca2e1fd19df3", size = 5587844, upload-time = "2025-11-06T22:07:45.033Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/17/b9/67240254166ae1eaa38dec32265e9153ac53645a6c6670ed36ad00722af8/ruff-0.14.4-py3-none-linux_armv6l.whl", hash = "sha256:e6604613ffbcf2297cd5dcba0e0ac9bd0c11dc026442dfbb614504e87c349518", size = 12606781, upload-time = "2025-11-06T22:07:01.841Z" }, - { url = "https://files.pythonhosted.org/packages/46/c8/09b3ab245d8652eafe5256ab59718641429f68681ee713ff06c5c549f156/ruff-0.14.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d99c0b52b6f0598acede45ee78288e5e9b4409d1ce7f661f0fa36d4cbeadf9a4", size = 12946765, upload-time = "2025-11-06T22:07:05.858Z" }, - { url = "https://files.pythonhosted.org/packages/14/bb/1564b000219144bf5eed2359edc94c3590dd49d510751dad26202c18a17d/ruff-0.14.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9358d490ec030f1b51d048a7fd6ead418ed0826daf6149e95e30aa67c168af33", size = 11928120, upload-time = "2025-11-06T22:07:08.023Z" }, - { url = "https://files.pythonhosted.org/packages/a3/92/d5f1770e9988cc0742fefaa351e840d9aef04ec24ae1be36f333f96d5704/ruff-0.14.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81b40d27924f1f02dfa827b9c0712a13c0e4b108421665322218fc38caf615c2", size = 12370877, upload-time = "2025-11-06T22:07:10.015Z" }, - { url = "https://files.pythonhosted.org/packages/e2/29/e9282efa55f1973d109faf839a63235575519c8ad278cc87a182a366810e/ruff-0.14.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f5e649052a294fe00818650712083cddc6cc02744afaf37202c65df9ea52efa5", size = 12408538, upload-time = "2025-11-06T22:07:13.085Z" }, - { url = "https://files.pythonhosted.org/packages/8e/01/930ed6ecfce130144b32d77d8d69f5c610e6d23e6857927150adf5d7379a/ruff-0.14.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa082a8f878deeba955531f975881828fd6afd90dfa757c2b0808aadb437136e", size = 13141942, upload-time = "2025-11-06T22:07:15.386Z" }, - { url = "https://files.pythonhosted.org/packages/6a/46/a9c89b42b231a9f487233f17a89cbef9d5acd538d9488687a02ad288fa6b/ruff-0.14.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1043c6811c2419e39011890f14d0a30470f19d47d197c4858b2787dfa698f6c8", size = 14544306, upload-time = "2025-11-06T22:07:17.631Z" }, - { url = "https://files.pythonhosted.org/packages/78/96/9c6cf86491f2a6d52758b830b89b78c2ae61e8ca66b86bf5a20af73d20e6/ruff-0.14.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a9f3a936ac27fb7c2a93e4f4b943a662775879ac579a433291a6f69428722649", size = 14210427, upload-time = "2025-11-06T22:07:19.832Z" }, - { url = "https://files.pythonhosted.org/packages/71/f4/0666fe7769a54f63e66404e8ff698de1dcde733e12e2fd1c9c6efb689cb5/ruff-0.14.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:95643ffd209ce78bc113266b88fba3d39e0461f0cbc8b55fb92505030fb4a850", size = 13658488, upload-time = "2025-11-06T22:07:22.32Z" }, - { url = "https://files.pythonhosted.org/packages/ee/79/6ad4dda2cfd55e41ac9ed6d73ef9ab9475b1eef69f3a85957210c74ba12c/ruff-0.14.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:456daa2fa1021bc86ca857f43fe29d5d8b3f0e55e9f90c58c317c1dcc2afc7b5", size = 13354908, upload-time = "2025-11-06T22:07:24.347Z" }, - { url = "https://files.pythonhosted.org/packages/b5/60/f0b6990f740bb15c1588601d19d21bcc1bd5de4330a07222041678a8e04f/ruff-0.14.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f911bba769e4a9f51af6e70037bb72b70b45a16db5ce73e1f72aefe6f6d62132", size = 13587803, upload-time = "2025-11-06T22:07:26.327Z" }, - { url = "https://files.pythonhosted.org/packages/c9/da/eaaada586f80068728338e0ef7f29ab3e4a08a692f92eb901a4f06bbff24/ruff-0.14.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:76158a7369b3979fa878612c623a7e5430c18b2fd1c73b214945c2d06337db67", size = 12279654, upload-time = "2025-11-06T22:07:28.46Z" }, - { url = "https://files.pythonhosted.org/packages/66/d4/b1d0e82cf9bf8aed10a6d45be47b3f402730aa2c438164424783ac88c0ed/ruff-0.14.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f3b8f3b442d2b14c246e7aeca2e75915159e06a3540e2f4bed9f50d062d24469", size = 12357520, upload-time = "2025-11-06T22:07:31.468Z" }, - { url = "https://files.pythonhosted.org/packages/04/f4/53e2b42cc82804617e5c7950b7079d79996c27e99c4652131c6a1100657f/ruff-0.14.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c62da9a06779deecf4d17ed04939ae8b31b517643b26370c3be1d26f3ef7dbde", size = 12719431, upload-time = "2025-11-06T22:07:33.831Z" }, - { url = "https://files.pythonhosted.org/packages/a2/94/80e3d74ed9a72d64e94a7b7706b1c1ebaa315ef2076fd33581f6a1cd2f95/ruff-0.14.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5a443a83a1506c684e98acb8cb55abaf3ef725078be40237463dae4463366349", size = 13464394, upload-time = "2025-11-06T22:07:35.905Z" }, - { url = "https://files.pythonhosted.org/packages/54/1a/a49f071f04c42345c793d22f6cf5e0920095e286119ee53a64a3a3004825/ruff-0.14.4-py3-none-win32.whl", hash = "sha256:643b69cb63cd996f1fc7229da726d07ac307eae442dd8974dbc7cf22c1e18fff", size = 12493429, upload-time = "2025-11-06T22:07:38.43Z" }, - { url = "https://files.pythonhosted.org/packages/bc/22/e58c43e641145a2b670328fb98bc384e20679b5774258b1e540207580266/ruff-0.14.4-py3-none-win_amd64.whl", hash = "sha256:26673da283b96fe35fa0c939bf8411abec47111644aa9f7cfbd3c573fb125d2c", size = 13635380, upload-time = "2025-11-06T22:07:40.496Z" }, - { url = "https://files.pythonhosted.org/packages/30/bd/4168a751ddbbf43e86544b4de8b5c3b7be8d7167a2a5cb977d274e04f0a1/ruff-0.14.4-py3-none-win_arm64.whl", hash = "sha256:dd09c292479596b0e6fec8cd95c65c3a6dc68e9ad17b8f2382130f87ff6a75bb", size = 12663065, upload-time = "2025-11-06T22:07:42.603Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/82/fa/fbb67a5780ae0f704876cb8ac92d6d76da41da4dc72b7ed3565ab18f2f52/ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1", size = 5615944, upload-time = "2025-11-13T19:58:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/31/c07e9c535248d10836a94e4f4e8c5a31a1beed6f169b31405b227872d4f4/ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594", size = 13171630, upload-time = "2025-11-13T19:57:54.894Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/283c62516dca697cd604c2796d1487396b7a436b2f0ecc3fd412aca470e0/ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72", size = 13413925, upload-time = "2025-11-13T19:57:59.181Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/aa319f4afc22cb6fcba2b9cdfc0f03bbf747e59ab7a8c5e90173857a1361/ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a", size = 12574040, upload-time = "2025-11-13T19:58:02.056Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7f/cb5845fcc7c7e88ed57f58670189fc2ff517fe2134c3821e77e29fd3b0c8/ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f", size = 13009755, upload-time = "2025-11-13T19:58:05.172Z" }, + { url = "https://files.pythonhosted.org/packages/21/d2/bcbedbb6bcb9253085981730687ddc0cc7b2e18e8dc13cf4453de905d7a0/ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68", size = 12937641, upload-time = "2025-11-13T19:58:08.345Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/e25de28a572bdd60ffc6bb71fc7fd25a94ec6a076942e372437649cbb02a/ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7", size = 13610854, upload-time = "2025-11-13T19:58:11.419Z" }, + { url = "https://files.pythonhosted.org/packages/7d/24/43bb3fd23ecee9861970978ea1a7a63e12a204d319248a7e8af539984280/ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78", size = 15061088, upload-time = "2025-11-13T19:58:14.551Z" }, + { url = "https://files.pythonhosted.org/packages/23/44/a022f288d61c2f8c8645b24c364b719aee293ffc7d633a2ca4d116b9c716/ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb", size = 14734717, upload-time = "2025-11-13T19:58:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/58/81/5c6ba44de7e44c91f68073e0658109d8373b0590940efe5bd7753a2585a3/ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2", size = 14028812, upload-time = "2025-11-13T19:58:20.533Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ef/41a8b60f8462cb320f68615b00299ebb12660097c952c600c762078420f8/ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19", size = 13825656, upload-time = "2025-11-13T19:58:23.345Z" }, + { url = "https://files.pythonhosted.org/packages/7c/00/207e5de737fdb59b39eb1fac806904fe05681981b46d6a6db9468501062e/ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4", size = 13959922, upload-time = "2025-11-13T19:58:26.537Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7e/fa1f5c2776db4be405040293618846a2dece5c70b050874c2d1f10f24776/ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1", size = 12932501, upload-time = "2025-11-13T19:58:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/67/d8/d86bf784d693a764b59479a6bbdc9515ae42c340a5dc5ab1dabef847bfaa/ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151", size = 12927319, upload-time = "2025-11-13T19:58:32.923Z" }, + { url = "https://files.pythonhosted.org/packages/ac/de/ee0b304d450ae007ce0cb3e455fe24fbcaaedae4ebaad6c23831c6663651/ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465", size = 13206209, upload-time = "2025-11-13T19:58:35.952Z" }, + { url = "https://files.pythonhosted.org/packages/33/aa/193ca7e3a92d74f17d9d5771a765965d2cf42c86e6f0fd95b13969115723/ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367", size = 13953709, upload-time = "2025-11-13T19:58:39.002Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f1/7119e42aa1d3bf036ffc9478885c2e248812b7de9abea4eae89163d2929d/ruff-0.14.5-py3-none-win32.whl", hash = "sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b", size = 12925808, upload-time = "2025-11-13T19:58:42.779Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9d/7c0a255d21e0912114784e4a96bf62af0618e2190cae468cd82b13625ad2/ruff-0.14.5-py3-none-win_amd64.whl", hash = "sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621", size = 14331546, upload-time = "2025-11-13T19:58:45.691Z" }, + { url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331, upload-time = "2025-11-13T19:58:48.434Z" }, ] [[package]] @@ -5759,24 +5759,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/50/ff/26a4ee48d0b66625a4e4028a055b9f25bc9d7c7b2d17d21a45137621a50d/soundfile-0.12.1-py2.py3-none-win_amd64.whl", hash = "sha256:0d86924c00b62552b650ddd28af426e3ff2d4dc2e9047dae5b3d8452e0a49a77", size = 1009109, upload-time = "2023-02-15T15:37:29.41Z" }, ] -[[package]] -name = "soundfile" -version = "0.12.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6f/96/5ff33900998bad58d5381fd1acfcdac11cbea4f08fc72ac1dc25ffb13f6a/soundfile-0.12.1.tar.gz", hash = "sha256:e8e1017b2cf1dda767aef19d2fd9ee5ebe07e050d430f77a0a7c66ba08b8cdae", size = 43184, upload-time = "2023-02-15T15:37:32.011Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/bc/cd845c2dbb4d257c744cd58a5bcdd9f6d235ca317e7e22e49564ec88dcd9/soundfile-0.12.1-py2.py3-none-any.whl", hash = "sha256:828a79c2e75abab5359f780c81dccd4953c45a2c4cd4f05ba3e233ddf984b882", size = 24030, upload-time = "2023-02-15T15:37:16.077Z" }, - { url = "https://files.pythonhosted.org/packages/c8/73/059c84343be6509b480013bf1eeb11b96c5f9eb48deff8f83638011f6b2c/soundfile-0.12.1-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:d922be1563ce17a69582a352a86f28ed8c9f6a8bc951df63476ffc310c064bfa", size = 1213305, upload-time = "2023-02-15T15:37:18.875Z" }, - { url = "https://files.pythonhosted.org/packages/71/87/31d2b9ed58975cec081858c01afaa3c43718eb0f62b5698a876d94739ad0/soundfile-0.12.1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:bceaab5c4febb11ea0554566784bcf4bc2e3977b53946dda2b12804b4fe524a8", size = 1075977, upload-time = "2023-02-15T15:37:21.938Z" }, - { url = "https://files.pythonhosted.org/packages/ad/bd/0602167a213d9184fc688b1086dc6d374b7ae8c33eccf169f9b50ce6568c/soundfile-0.12.1-py2.py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:2dc3685bed7187c072a46ab4ffddd38cef7de9ae5eb05c03df2ad569cf4dacbc", size = 1257765, upload-time = "2023-03-24T08:21:58.716Z" }, - { url = "https://files.pythonhosted.org/packages/c1/07/7591f4efd29e65071c3a61b53725036ea8f73366a4920a481ebddaf8d0ca/soundfile-0.12.1-py2.py3-none-manylinux_2_31_x86_64.whl", hash = "sha256:074247b771a181859d2bc1f98b5ebf6d5153d2c397b86ee9e29ba602a8dfe2a6", size = 1174746, upload-time = "2023-02-15T15:37:24.771Z" }, - { url = "https://files.pythonhosted.org/packages/03/0f/49941ed8a2d94e5b36ea94346fb1d2b22e847fede902e05be4c96f26be7d/soundfile-0.12.1-py2.py3-none-win32.whl", hash = "sha256:59dfd88c79b48f441bbf6994142a19ab1de3b9bb7c12863402c2bc621e49091a", size = 888234, upload-time = "2023-02-15T15:37:27.078Z" }, - { url = "https://files.pythonhosted.org/packages/50/ff/26a4ee48d0b66625a4e4028a055b9f25bc9d7c7b2d17d21a45137621a50d/soundfile-0.12.1-py2.py3-none-win_amd64.whl", hash = "sha256:0d86924c00b62552b650ddd28af426e3ff2d4dc2e9047dae5b3d8452e0a49a77", size = 1009109, upload-time = "2023-02-15T15:37:29.41Z" }, -] - [[package]] name = "sphinx" version = "6.1.3" From 1d19a8ac980dcb983c7002b7064f07fb0a99f5e2 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Fri, 14 Nov 2025 11:26:18 +0100 Subject: [PATCH 14/17] fix mypy --- .../declarative/agent_framework_declarative/_loader.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/python/packages/declarative/agent_framework_declarative/_loader.py b/python/packages/declarative/agent_framework_declarative/_loader.py index 4aec426d7a..3a43b31196 100644 --- a/python/packages/declarative/agent_framework_declarative/_loader.py +++ b/python/packages/declarative/agent_framework_declarative/_loader.py @@ -263,7 +263,9 @@ def _get_client(self, prompt_agent: PromptAgent) -> ChatClientProtocol: if not self.connections: raise ValueError("Connections must be provided to resolve ReferenceConnection") # find the referenced connection - if value := self.connections.get(prompt_agent.model.connection.name): + 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( @@ -287,7 +289,7 @@ def _get_client(self, prompt_agent: PromptAgent) -> ChatClientProtocol: 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) + 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.""" From 4654e0645697b339f5c53b34dea9e01132b02ef0 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Fri, 14 Nov 2025 12:17:55 +0100 Subject: [PATCH 15/17] added readme to samples folder --- .../getting_started/declarative/README.md | 261 ++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 python/samples/getting_started/declarative/README.md diff --git a/python/samples/getting_started/declarative/README.md b/python/samples/getting_started/declarative/README.md new file mode 100644 index 0000000000..0956c33a67 --- /dev/null +++ b/python/samples/getting_started/declarative/README.md @@ -0,0 +1,261 @@ +# Declarative Agent Samples + +This folder contains sample code demonstrating how to use the **Microsoft Agent Framework Declarative** package to create agents from YAML specifications. The declarative approach allows you to define your agents in a structured, configuration-driven way, separating agent behavior from implementation details. + +## Installation + +Install the declarative package via pip: + +```bash +pip install agent-framework-declarative --pre +``` + +## What is Declarative Agent Framework? + +The declarative package provides support for building agents based on YAML specifications. This approach offers several benefits: + +- **Cross-Platform Compatibility**: Write one YAML definition and create agents in both Python and .NET - the same agent configuration works across both platforms +- **Separation of Concerns**: Define agent behavior in YAML files separate from your implementation code +- **Reusability**: Share and version agent configurations independently across projects and languages +- **Flexibility**: Easily swap between different LLM providers and configurations +- **Maintainability**: Update agent instructions and settings without modifying code + +## Samples in This Folder + +### 1. **Get Weather Agent** ([`get_weather_agent.py`](./get_weather_agent.py)) + +Demonstrates how to create an agent with custom function tools using the declarative approach. + +- Uses Azure OpenAI Responses client +- Shows how to bind Python functions to the agent using the `bindings` parameter +- Loads agent configuration from `agent-samples/chatclient/GetWeather.yaml` +- Implements a simple weather lookup function tool + +**Key concepts**: Function binding, Azure OpenAI integration, tool usage + +### 2. **Microsoft Learn Agent** ([`microsoft_learn_agent.py`](./microsoft_learn_agent.py)) + +Shows how to create an agent that can search and retrieve information from Microsoft Learn documentation using the Model Context Protocol (MCP). + +- Uses Azure AI Foundry client with MCP server integration +- Demonstrates async context managers for proper resource cleanup +- Loads agent configuration from `agent-samples/foundry/MicrosoftLearnAgent.yaml` +- Uses Azure CLI credentials for authentication +- Leverages MCP to access Microsoft documentation tools + +**Requirements**: `pip install agent-framework-azure-ai --pre` + +**Key concepts**: Azure AI Foundry integration, MCP server usage, async patterns, resource management + +### 3. **Azure OpenAI Responses Agent** ([`azure_openai_responses_agent.py`](./azure_openai_responses_agent.py)) + +Illustrates a basic agent using Azure OpenAI with structured responses. + +- Uses Azure OpenAI Responses client +- Shows how to pass credentials via `client_kwargs` +- Loads agent configuration from `agent-samples/azure/AzureOpenAIResponses.yaml` +- Demonstrates accessing structured response data + +**Key concepts**: Azure OpenAI integration, credential management, structured outputs + +### 4. **OpenAI Responses Agent** ([`openai_responses_agent.py`](./openai_responses_agent.py)) + +Demonstrates the simplest possible agent using OpenAI directly. + +- Uses OpenAI API (requires `OPENAI_API_KEY` environment variable) +- Shows minimal configuration needed for basic agent creation +- Loads agent configuration from `agent-samples/openai/OpenAIResponses.yaml` + +**Key concepts**: OpenAI integration, minimal setup, environment-based configuration + +## Agent Samples Repository + +All the YAML configuration files referenced in these samples are located in the [`agent-samples`](../../../../agent-samples/) folder at the repository root. This folder contains declarative agent specifications organized by provider: + +- **`agent-samples/azure/`** - Azure OpenAI agent configurations +- **`agent-samples/chatclient/`** - Chat client agent configurations with tools +- **`agent-samples/foundry/`** - Azure AI Foundry agent configurations +- **`agent-samples/openai/`** - OpenAI agent configurations + +**Important**: These YAML files are **platform-agnostic** and work with both Python and .NET implementations of the Agent Framework. You can use the exact same YAML definition to create agents in either language, making it easy to share agent configurations across different technology stacks. + +These YAML files define: +- Agent instructions and system prompts +- Model selection and parameters +- Tool and function configurations +- Provider-specific settings +- MCP server integrations (where applicable) + +## Common Patterns + +### Creating an Agent from YAML String + +```python +from agent_framework.declarative import AgentFactory + +with open("agent.yaml", "r") as f: + yaml_str = f.read() + +agent = AgentFactory().create_agent_from_yaml(yaml_str) +# response = await agent.run("Your query here") +``` + +### Creating an Agent from YAML Path + +```python +from pathlib import Path +from agent_framework.declarative import AgentFactory + +yaml_path = Path("agent.yaml") +agent = AgentFactory().create_agent_from_yaml_path(yaml_path) +# response = await agent.run("Your query here") +``` + +### Binding Custom Functions + +```python +from pathlib import Path +from agent_framework.declarative import AgentFactory + +def my_function(param: str) -> str: + return f"Result: {param}" + +agent_factory = AgentFactory(bindings={"my_function": my_function}) +agent = agent_factory.create_agent_from_yaml_path(Path("agent_with_tool.yaml")) +``` + +### Using Credentials + +```python +from pathlib import Path +from agent_framework.declarative import AgentFactory +from azure.identity import AzureCliCredential + +agent = AgentFactory( + client_kwargs={"credential": AzureCliCredential()} +).create_agent_from_yaml_path(Path("azure_agent.yaml")) +``` + +### Adding Custom Provider Mappings + +```python +from pathlib import Path +from agent_framework.declarative import AgentFactory +# from my_custom_module import MyCustomChatClient + +# Register a custom provider mapping +agent_factory = AgentFactory( + additional_mappings={ + "MyProvider": { + "package": "my_custom_module", + "name": "MyCustomChatClient", + "model_id_field": "model_id", + } + } +) + +# Now you can reference "MyProvider" in your YAML +# Example YAML snippet: +# model: +# provider: MyProvider +# id: my-model-name + +agent = agent_factory.create_agent_from_yaml_path(Path("custom_provider.yaml")) +``` + +This allows you to extend the declarative framework with custom chat client implementations. The mapping requires: +- **package**: The Python package/module to import from +- **name**: The class name of your ChatClientProtocol implementation +- **model_id_field**: The constructor parameter name that accepts the value of the `model.id` field from the YAML + +You can reference your custom provider using either `Provider.ApiType` format or just `Provider` in your YAML configuration, as long as it matches the registered mapping. + +### Using PowerFx Formulas in YAML + +The declarative framework supports PowerFx formulas in YAML values, enabling dynamic configuration based on environment variables and conditional logic. Prefix any value with `=` to evaluate it as a PowerFx expression. + +#### Environment Variable Lookup + +Access environment variables using the `Env.` syntax: + +```yaml +model: + connection: + kind: key + apiKey: =Env.OPENAI_API_KEY + endpoint: =Env.BASE_URL & "/v1" # String concatenation with & + + options: + temperature: 0.7 + maxOutputTokens: =Env.MAX_TOKENS # Will be converted to appropriate type +``` + +#### Conditional Logic + +Use PowerFx operators for conditional configuration. This is particularly useful for adjusting parameters based on which model is being used: + +```yaml +model: + id: =Env.MODEL_NAME + options: + # Set max tokens based on model - using conditional logic + maxOutputTokens: =If(Env.MODEL_NAME = "gpt-5", 8000, 4000) + + # Adjust temperature for different environments + temperature: =If(Env.ENVIRONMENT = "production", 0.3, 0.7) + + # Use logical operators for complex conditions + seed: =If(Env.ENVIRONMENT = "production" And Env.DETERMINISTIC = "true", 42, Blank()) +``` + +#### Supported PowerFx Features + +- **String operations**: Concatenation (`&`), comparison (`=`, `<>`), substring testing (`in`, `exactin`) +- **Logical operators**: `And`, `Or`, `Not` (also `&&`, `||`, `!`) +- **Arithmetic**: Basic math operations (`+`, `-`, `*`, `/`) +- **Conditional**: `If(condition, true_value, false_value)` +- **Environment access**: `Env.` + +Example with multiple features: + +```yaml +instructions: =If( + Env.USE_EXPERT_MODE = "true", + "You are an expert AI assistant with advanced capabilities. " & Env.CUSTOM_INSTRUCTIONS, + "You are a helpful AI assistant." +) + +model: + options: + stopSequences: =If("gpt-4" in Env.MODEL_NAME, ["END", "STOP"], ["END"]) +``` + +**Note**: PowerFx evaluation happens when the YAML is loaded, not at runtime. Use environment variables (via `.env` file or `env_file` parameter) to make configurations flexible across environments. + +## Running the Samples + +Each sample can be run independently. Make sure you have the required environment variables set: + +- For Azure samples: Ensure you're logged in via Azure CLI (`az login`) +- For OpenAI samples: Set `OPENAI_APIKEY` environment variable + +```bash +# Run a specific sample +python get_weather_agent.py +python microsoft_learn_agent.py +python azure_openai_responses_agent.py +python openai_responses_agent.py +``` + +## Learn More + +- [Agent Framework Declarative Package](../../../packages/declarative/) - Main declarative package documentation +- [Agent Samples](../../../../agent-samples/) - Additional declarative agent YAML specifications +- [Agent Framework Core](../../../packages/core/) - Core agent framework documentation + +## Next Steps + +1. Explore the YAML files in the `agent-samples` folder to understand the configuration format +2. Try modifying the samples to use different models or instructions +3. Create your own declarative agent configurations +4. Build custom function tools and bind them to your agents From 121036458c1b37bc2ff1958ebe8b20ecbc304074 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Mon, 17 Nov 2025 14:58:57 +0100 Subject: [PATCH 16/17] fixes from review --- .../agent_framework_declarative/_loader.py | 17 ++++++++++------- .../agent_framework_declarative/_models.py | 7 ++----- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/python/packages/declarative/agent_framework_declarative/_loader.py b/python/packages/declarative/agent_framework_declarative/_loader.py index 3a43b31196..b5ae1683ba 100644 --- a/python/packages/declarative/agent_framework_declarative/_loader.py +++ b/python/packages/declarative/agent_framework_declarative/_loader.py @@ -371,14 +371,17 @@ def _parse_tool(self, tool_resource: Tool) -> ToolProtocol: 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 - } - else: - approval_mode = { - "never_require_approval": tool_resource.approvalMode.neverRequireApprovalTools - } + 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, diff --git a/python/packages/declarative/agent_framework_declarative/_models.py b/python/packages/declarative/agent_framework_declarative/_models.py index 82af55fba3..ecf7ab14ca 100644 --- a/python/packages/declarative/agent_framework_declarative/_models.py +++ b/python/packages/declarative/agent_framework_declarative/_models.py @@ -93,11 +93,8 @@ def from_dict( # We're being called on a subclass, use the normal from_dict return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[misc] - # Filter out 'type' field which is not a Property parameter - if "type" in value: - value = dict(value) if "enum" not in value else value # Only copy if not already copied - value.pop("type", None) - + # Filter out 'type' (if it exists) field which is not a Property parameter + value.pop("type", None) kind = value.get("kind", "") if kind == "array": return ArrayProperty.from_dict(value, dependencies=dependencies) From bec3f6efac218a4948dd3d4b8c37318ff0c78d78 Mon Sep 17 00:00:00 2001 From: eavanvalkenburg Date: Wed, 19 Nov 2025 17:04:42 +0100 Subject: [PATCH 17/17] undid test file rename --- .../azurefunctions/tests/{test_func_models.py => test_models.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename python/packages/azurefunctions/tests/{test_func_models.py => test_models.py} (100%) diff --git a/python/packages/azurefunctions/tests/test_func_models.py b/python/packages/azurefunctions/tests/test_models.py similarity index 100% rename from python/packages/azurefunctions/tests/test_func_models.py rename to python/packages/azurefunctions/tests/test_models.py