Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,7 @@ def create_agent_from_dict(self, agent_def: dict[str, Any]) -> Agent:
name=prompt_agent.name,
description=prompt_agent.description,
instructions=prompt_agent.instructions,
**chat_options,
default_options=chat_options, # type: ignore[arg-type]
)

async def create_agent_from_yaml_path_async(self, yaml_path: str | Path) -> Agent:
Expand Down Expand Up @@ -569,7 +569,7 @@ async def create_agent_from_dict_async(self, agent_def: dict[str, Any]) -> Agent
name=prompt_agent.name,
description=prompt_agent.description,
instructions=prompt_agent.instructions,
**chat_options,
default_options=chat_options, # type: ignore[arg-type]
)

async def _create_agent_with_provider(self, prompt_agent: PromptAgent, mapping: ProviderTypeMapping) -> Agent:
Expand Down
47 changes: 37 additions & 10 deletions python/packages/declarative/agent_framework_declarative/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,32 @@
import os
from collections.abc import MutableMapping
from contextvars import ContextVar
from typing import Any, Literal, TypeVar, Union
from typing import TYPE_CHECKING, Any, Literal, TypeVar, Union, overload

from agent_framework._serialization import SerializationMixin

try:
if TYPE_CHECKING:
from powerfx import Engine

engine: Engine | None = Engine()
except (ImportError, RuntimeError):
# ImportError: powerfx package not installed
# RuntimeError: .NET runtime not available or misconfigured
engine = None
_engine_initialized = False
_engine: Engine | None = None


def _get_engine() -> Engine | None:
"""Lazily initialize the PowerFx engine on first use."""
global _engine_initialized, _engine
if not _engine_initialized:
_engine_initialized = True
try:
from powerfx import Engine

_engine = Engine()
except (ImportError, RuntimeError):
# ImportError: powerfx package not installed
# RuntimeError: .NET runtime not available or misconfigured
pass
return _engine

from typing import overload

logger = logging.getLogger("agent_framework.declarative")

Expand Down Expand Up @@ -47,6 +59,7 @@ def _try_powerfx_eval(value: str | None, log_value: bool = True) -> str | None:
return value
if not value.startswith("="):
return value
engine = _get_engine()
if engine is None:
logger.warning(
"PowerFx engine not available for evaluating values starting with '='. "
Expand Down Expand Up @@ -110,8 +123,12 @@ 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[attr-defined, no-any-return]

# Filter out 'type' (if it exists) field which is not a Property parameter
value.pop("type", None)
# The YAML spec uses 'type' for the data type, but Property stores it as 'kind'
if "type" in value:
if "kind" not in value:
value["kind"] = value.pop("type")
else:
value.pop("type")
kind = value.get("kind", "")
if kind == "array":
return ArrayProperty.from_dict(value, dependencies=dependencies)
Expand Down Expand Up @@ -224,11 +241,21 @@ 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 = {}
required_fields: list[str] = []
for prop in json_schema.get("properties", []):
prop_name = prop.pop("name")
prop["type"] = prop.pop("kind", None)
# Convert property-level 'required' boolean to a top-level 'required' array
if prop.pop("required", False):
required_fields.append(prop_name)
# Remove empty enum arrays
if not prop.get("enum"):
prop.pop("enum", None)
new_props[prop_name] = prop
json_schema["type"] = "object"
json_schema["properties"] = new_props
if required_fields:
json_schema["required"] = required_fields
return json_schema


Expand Down
52 changes: 52 additions & 0 deletions python/packages/declarative/tests/test_declarative_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,58 @@ def test_create_agent_from_dict_without_model_or_client_raises(self):
with pytest.raises(DeclarativeLoaderError, match="ChatClient must be provided"):
factory.create_agent_from_dict(agent_def)

def test_create_agent_from_dict_output_schema_in_default_options(self):
"""Test that outputSchema is passed as response_format in Agent.default_options."""
from unittest.mock import MagicMock

from pydantic import BaseModel

from agent_framework_declarative import AgentFactory

agent_def = {
"kind": "Prompt",
"name": "TestAgent",
"instructions": "You are helpful.",
"outputSchema": {
"properties": {
"answer": {"type": "string", "required": True, "description": "The answer."},
},
},
}

mock_client = MagicMock()
factory = AgentFactory(client=mock_client)
agent = factory.create_agent_from_dict(agent_def)

assert "response_format" in agent.default_options
assert isinstance(agent.default_options["response_format"], type)
assert issubclass(agent.default_options["response_format"], BaseModel)

def test_create_agent_from_dict_chat_options_in_default_options(self):
"""Test that chat options (temperature, top_p) are in Agent.default_options."""
from unittest.mock import MagicMock

from agent_framework_declarative import AgentFactory

agent_def = {
"kind": "Prompt",
"name": "TestAgent",
"instructions": "You are helpful.",
"model": {
"options": {
"temperature": 0.7,
"topP": 0.9,
},
},
}

mock_client = MagicMock()
factory = AgentFactory(client=mock_client)
agent = factory.create_agent_from_dict(agent_def)

assert agent.default_options.get("temperature") == 0.7
assert agent.default_options.get("top_p") == 0.9


class TestAgentFactorySafeMode:
"""Tests for AgentFactory safe_mode parameter."""
Expand Down
67 changes: 67 additions & 0 deletions python/packages/declarative/tests/test_declarative_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,50 @@ def test_property_from_dict(self):
assert prop.description == "A test property"
assert prop.required is True

def test_property_from_dict_type_maps_to_kind(self):
"""Test that 'type' field in YAML is mapped to 'kind' internally."""
data = {
"name": "test_prop",
"type": "string",
"description": "A test property",
"required": True,
}
prop = Property.from_dict(data)
assert prop.name == "test_prop"
assert prop.kind == "string"

def test_property_from_dict_kind_takes_precedence_over_type(self):
"""Test that 'kind' takes precedence when both 'type' and 'kind' are present."""
data = {
"name": "test_prop",
"type": "integer",
"kind": "string",
}
prop = Property.from_dict(data)
assert prop.kind == "string"

def test_property_from_dict_type_dispatches_to_array(self):
"""Test that 'type: array' correctly dispatches to ArrayProperty."""
data = {
"name": "test_array",
"type": "array",
"items": {"type": "string"},
}
prop = Property.from_dict(data)
assert isinstance(prop, ArrayProperty)
assert prop.kind == "array"

def test_property_from_dict_type_dispatches_to_object(self):
"""Test that 'type: object' correctly dispatches to ObjectProperty."""
data = {
"name": "test_object",
"type": "object",
"properties": {"field": {"type": "string"}},
}
prop = Property.from_dict(data)
assert isinstance(prop, ObjectProperty)
assert prop.kind == "object"


class TestArrayProperty:
"""Tests for ArrayProperty class."""
Expand Down Expand Up @@ -230,6 +274,29 @@ def test_property_schema_with_dict_properties(self):
assert age_prop.kind == "integer"
assert age_prop.required is True

def test_property_schema_with_type_field_produces_correct_json_schema(self):
"""Test that PropertySchema with 'type' fields (YAML spec format) produces valid JSON schema."""
data = {
"properties": {
"language": {"type": "string", "required": True, "description": "The language."},
"answer": {"type": "string", "required": False, "description": "The answer."},
},
}
schema = PropertySchema.from_dict(data)
assert len(schema.properties) == 2

lang_prop = next(p for p in schema.properties if p.name == "language")
assert lang_prop.kind == "string"

json_schema = schema.to_json_schema()
assert json_schema["type"] == "object"
assert json_schema["properties"]["language"]["type"] == "string"
assert json_schema["properties"]["answer"]["type"] == "string"
# required is a top-level array, not a per-property boolean
assert json_schema["required"] == ["language"]
assert "required" not in json_schema["properties"]["language"]
assert "required" not in json_schema["properties"]["answer"]


class TestConnection:
"""Tests for Connection base class."""
Expand Down
3 changes: 3 additions & 0 deletions python/samples/02-agents/declarative/get_weather_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
from agent_framework.azure import AzureOpenAIResponsesClient
from agent_framework.declarative import AgentFactory
from azure.identity import AzureCliCredential
from dotenv import load_dotenv

load_dotenv()


def get_weather(location: str, unit: Literal["celsius", "fahrenheit"] = "celsius") -> str:
Expand Down
5 changes: 4 additions & 1 deletion python/samples/02-agents/declarative/inline_yaml.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@

from agent_framework.declarative import AgentFactory
from azure.identity.aio import AzureCliCredential
from dotenv import load_dotenv

load_dotenv()

"""
This sample shows how to create an agent using an inline YAML string rather than a file.
Expand Down Expand Up @@ -34,7 +37,7 @@ async def main():
# create the agent from the yaml
async with (
AzureCliCredential() as credential,
AgentFactory(client_kwargs={"credential": credential}).create_agent_from_yaml(yaml_definition) as agent,
AgentFactory(client_kwargs={"credential": credential}, safe_mode=False).create_agent_from_yaml(yaml_definition) as agent,
):
response = await agent.run("What can you do for me?")
print("Agent response:", response.text)
Expand Down
1 change: 0 additions & 1 deletion python/samples/02-agents/declarative/mcp_tool_yaml.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
from agent_framework.declarative import AgentFactory
from dotenv import load_dotenv

# Load environment variables
load_dotenv()

# Example 1: OpenAI.Responses with API key authentication
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

from agent_framework.declarative import AgentFactory
from azure.identity.aio import AzureCliCredential
from dotenv import load_dotenv

load_dotenv()


async def main():
Expand All @@ -15,7 +18,7 @@ async def main():
# create the agent from the yaml
async with (
AzureCliCredential() as credential,
AgentFactory(client_kwargs={"credential": credential}).create_agent_from_yaml_path(yaml_path) as agent,
AgentFactory(client_kwargs={"credential": credential}, safe_mode=False).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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
from pathlib import Path

from agent_framework.declarative import AgentFactory
from dotenv import load_dotenv

load_dotenv()


async def main():
Expand All @@ -16,7 +19,7 @@ async def main():
yaml_str = f.read()

# create the agent from the yaml
agent = AgentFactory().create_agent_from_yaml(yaml_str)
agent = AgentFactory(safe_mode=False).create_agent_from_yaml(yaml_str)
# use the agent
response = await agent.run("Why is the sky blue, answer in Dutch?")
# Use response.value with try/except for safe parsing
Expand Down