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 @@ -605,10 +605,11 @@ async def _create_agent_with_provider(self, prompt_agent: PromptAgent, mapping:
# Parse tools
tools = self._parse_tools(prompt_agent.tools) if prompt_agent.tools else None

# Parse response format
response_format = None
# Parse response format into default_options
default_options: dict[str, Any] | None = None
if prompt_agent.outputSchema:
response_format = _create_model_from_json_schema("agent", prompt_agent.outputSchema.to_json_schema())
default_options = {"response_format": response_format}

# Create the agent using the provider
# The provider's create_agent returns a Agent directly
Expand All @@ -620,7 +621,7 @@ async def _create_agent_with_provider(self, prompt_agent: PromptAgent, mapping:
instructions=prompt_agent.instructions,
description=prompt_agent.description,
tools=tools,
response_format=response_format,
default_options=default_options,
),
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,16 @@ async def handle_invoke_azure_agent(ctx: ActionContext) -> AsyncGenerator[Workfl
max_iterations = 100 # Safety limit

# Start external loop if configured
# Build options for kwargs propagation to agent tools
run_kwargs = ctx.run_kwargs
options: dict[str, Any] | None = None
if run_kwargs:
# Merge caller-provided options to avoid duplicate keyword argument
options = dict(run_kwargs.get("options") or {})
options["additional_function_arguments"] = run_kwargs
# Exclude 'options' from splat to avoid TypeError on duplicate keyword
run_kwargs = {k: v for k, v in run_kwargs.items() if k != "options"}

while True:
# Invoke the agent
try:
Expand All @@ -337,7 +347,7 @@ async def handle_invoke_azure_agent(ctx: ActionContext) -> AsyncGenerator[Workfl
updates: list[Any] = []
tool_calls: list[Any] = []

async for chunk in agent.run(messages, stream=True):
async for chunk in agent.run(messages, stream=True, options=options, **run_kwargs):
updates.append(chunk)

# Yield streaming events for text chunks
Expand Down Expand Up @@ -403,7 +413,7 @@ async def handle_invoke_azure_agent(ctx: ActionContext) -> AsyncGenerator[Workfl

except TypeError:
# Agent doesn't support streaming, fall back to non-streaming
response = await agent.run(messages)
response = await agent.run(messages, options=options, **run_kwargs)

text = response.text
response_messages = response.messages
Expand Down Expand Up @@ -570,14 +580,24 @@ async def handle_invoke_prompt_agent(ctx: ActionContext) -> AsyncGenerator[Workf

logger.debug(f"InvokePromptAgent: calling '{agent_name}' with {len(messages)} messages")

# Build options for kwargs propagation to agent tools
prompt_run_kwargs = ctx.run_kwargs
prompt_options: dict[str, Any] | None = None
if prompt_run_kwargs:
# Merge caller-provided options to avoid duplicate keyword argument
prompt_options = dict(prompt_run_kwargs.get("options") or {})
prompt_options["additional_function_arguments"] = prompt_run_kwargs
# Exclude 'options' from splat to avoid TypeError on duplicate keyword
prompt_run_kwargs = {k: v for k, v in prompt_run_kwargs.items() if k != "options"}

# Invoke the agent
try:
if hasattr(agent, "run"):
# Try streaming first
try:
updates: list[Any] = []

async for chunk in agent.run(messages, stream=True):
async for chunk in agent.run(messages, stream=True, options=prompt_options, **prompt_run_kwargs):
updates.append(chunk)

if hasattr(chunk, "text") and chunk.text:
Expand Down Expand Up @@ -607,7 +627,7 @@ async def handle_invoke_prompt_agent(ctx: ActionContext) -> AsyncGenerator[Workf

except TypeError:
# Agent doesn't support streaming, fall back to non-streaming
response = await agent.run(messages)
response = await agent.run(messages, options=prompt_options, **prompt_run_kwargs)
text = response.text
response_messages = response.messages

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,13 @@
WorkflowContext,
)
from agent_framework._workflows._state import State
from powerfx import Engine

try:
from powerfx import Engine
except (ImportError, RuntimeError):
# ImportError: powerfx package not installed
# RuntimeError: .NET runtime not available or misconfigured
Engine = None # type: ignore[assignment, misc]

if sys.version_info >= (3, 11):
from typing import TypedDict # type: ignore # pragma: no cover
Expand Down Expand Up @@ -339,7 +345,8 @@ def eval(self, expression: str) -> Any:
undefined variables (matching legacy fallback parser behavior).

Raises:
ImportError: If the powerfx package is not installed.
RuntimeError: If the powerfx package is not installed and the
expression requires PowerFx evaluation.
"""
if not expression:
return expression
Expand All @@ -363,6 +370,13 @@ def eval(self, expression: str) -> Any:
# Replace them with their evaluated results before sending to PowerFx
formula = self._preprocess_custom_functions(formula)

if Engine is None:
raise RuntimeError(
f"PowerFx is not available (dotnet runtime not installed). "
f"Expression '={formula[:80]}' cannot be evaluated. "
f"Install dotnet and the powerfx package for full PowerFx support."
)

engine = Engine()
symbols = self._to_powerfx_symbols()
try:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -656,10 +656,22 @@ async def _invoke_agent_and_store_results(
if isinstance(messages_for_agent, list) and messages_for_agent:
_validate_conversation_history(messages_for_agent, agent_name)

# Retrieve kwargs passed to workflow.run() so they propagate to agent tools
from agent_framework._workflows._const import WORKFLOW_RUN_KWARGS_KEY

run_kwargs: dict[str, Any] = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {})
options: dict[str, Any] | None = None
if run_kwargs:
# Merge caller-provided options to avoid duplicate keyword argument
options = dict(run_kwargs.get("options") or {})
options["additional_function_arguments"] = run_kwargs
# Exclude 'options' from splat to avoid TypeError on duplicate keyword
run_kwargs = {k: v for k, v in run_kwargs.items() if k != "options"}

# Use run() method to get properly structured messages (including tool calls and results)
# This is critical for multi-turn conversations where tool calls must be followed
# by their results in the message history
result: Any = await agent.run(messages_for_agent)
result: Any = await agent.run(messages_for_agent, options=options, **run_kwargs)
if hasattr(result, "text") and result.text:
accumulated_response = str(result.text)
if auto_send:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from __future__ import annotations

from collections.abc import AsyncGenerator, Callable
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable

from agent_framework import get_logger
Expand Down Expand Up @@ -44,6 +44,9 @@ class ActionContext:
bindings: dict[str, Any]
"""Function bindings for tool calls."""

run_kwargs: dict[str, Any] = field(default_factory=dict)
"""Kwargs from workflow.run() to forward to agent invocations."""

@property
def action_id(self) -> str | None:
"""Get the action's unique identifier."""
Expand Down
104 changes: 104 additions & 0 deletions python/packages/declarative/tests/test_declarative_loader.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# Copyright (c) Microsoft. All rights reserved.

import builtins
import sys
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
import yaml
Expand Down Expand Up @@ -905,3 +907,105 @@ def test_mcp_tool_with_remote_connection_with_endpoint(self):

# Verify project_connection_id is set from connection name
assert mcp_tool.get("project_connection_id") == "my-oauth-connection"


class TestProviderResponseFormat:
"""response_format from outputSchema must be passed inside default_options."""

@staticmethod
def _make_mock_prompt_agent(*, with_output_schema: bool = False) -> MagicMock:
"""Create a mock PromptAgent to avoid serialization complexity."""
mock_model = MagicMock()
mock_model.id = "gpt-4"
mock_model.connection = None

agent = MagicMock()
agent.name = "test-agent"
agent.description = "test"
agent.instructions = "be helpful"
agent.model = mock_model
agent.tools = None

if with_output_schema:
mock_schema = MagicMock()
mock_schema.to_json_schema.return_value = {
"type": "object",
"properties": {"answer": {"type": "string"}},
}
agent.outputSchema = mock_schema
else:
agent.outputSchema = None

return agent

@staticmethod
def _make_mock_provider() -> tuple[MagicMock, AsyncMock]:
"""Create a mock provider class and its instance."""
mock_agent = MagicMock()
mock_provider_instance = AsyncMock()
mock_provider_instance.create_agent = AsyncMock(return_value=mock_agent)
mock_provider_class = MagicMock(return_value=mock_provider_instance)
return mock_provider_class, mock_provider_instance

@pytest.mark.asyncio
async def test_response_format_in_default_options(self):
"""Provider.create_agent() should receive response_format inside default_options."""
from agent_framework_declarative._loader import AgentFactory

prompt_agent = self._make_mock_prompt_agent(with_output_schema=True)
mock_provider_class, mock_provider_instance = self._make_mock_provider()

mapping = {"package": "some_module", "name": "SomeProvider"}
factory = AgentFactory()

original_import = builtins.__import__

def mock_import(name, *args, **kwargs):
if name == "some_module":
mod = MagicMock()
mod.SomeProvider = mock_provider_class
return mod
return original_import(name, *args, **kwargs)

with (
patch.object(builtins, "__import__", side_effect=mock_import),
patch.object(factory, "_parse_tools", return_value=None),
):
await factory._create_agent_with_provider(prompt_agent, mapping)

mock_provider_instance.create_agent.assert_called_once()
call_kwargs = mock_provider_instance.create_agent.call_args.kwargs

assert "response_format" not in call_kwargs
default_options = call_kwargs.get("default_options")
assert default_options is not None
assert "response_format" in default_options

@pytest.mark.asyncio
async def test_no_default_options_without_output_schema(self):
"""When there's no outputSchema, default_options should be None."""
from agent_framework_declarative._loader import AgentFactory

prompt_agent = self._make_mock_prompt_agent(with_output_schema=False)
mock_provider_class, mock_provider_instance = self._make_mock_provider()

mapping = {"package": "some_module", "name": "SomeProvider"}
factory = AgentFactory()

original_import = builtins.__import__

def mock_import(name, *args, **kwargs):
if name == "some_module":
mod = MagicMock()
mod.SomeProvider = mock_provider_class
return mod
return original_import(name, *args, **kwargs)

with (
patch.object(builtins, "__import__", side_effect=mock_import),
patch.object(factory, "_parse_tools", return_value=None),
):
await factory._create_agent_with_provider(prompt_agent, mapping)

call_kwargs = mock_provider_instance.create_agent.call_args.kwargs
assert call_kwargs.get("default_options") is None
Loading
Loading