Skip to content

Commit ff91473

Browse files
authored
Python: Fix declarative package powerfx import crash and response_format kwarg error (#3841)
* Fix declarative package powerfx import crash and response_format kwarg error * Address PR feedback. Propagate kwargs for declarative workflows * move tests * Fix options merge logic
1 parent 692fcd1 commit ff91473

8 files changed

Lines changed: 495 additions & 11 deletions

File tree

python/packages/declarative/agent_framework_declarative/_loader.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -605,10 +605,11 @@ async def _create_agent_with_provider(self, prompt_agent: PromptAgent, mapping:
605605
# Parse tools
606606
tools = self._parse_tools(prompt_agent.tools) if prompt_agent.tools else None
607607

608-
# Parse response format
609-
response_format = None
608+
# Parse response format into default_options
609+
default_options: dict[str, Any] | None = None
610610
if prompt_agent.outputSchema:
611611
response_format = _create_model_from_json_schema("agent", prompt_agent.outputSchema.to_json_schema())
612+
default_options = {"response_format": response_format}
612613

613614
# Create the agent using the provider
614615
# The provider's create_agent returns a Agent directly
@@ -620,7 +621,7 @@ async def _create_agent_with_provider(self, prompt_agent: PromptAgent, mapping:
620621
instructions=prompt_agent.instructions,
621622
description=prompt_agent.description,
622623
tools=tools,
623-
response_format=response_format,
624+
default_options=default_options,
624625
),
625626
)
626627

python/packages/declarative/agent_framework_declarative/_workflows/_actions_agents.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,16 @@ async def handle_invoke_azure_agent(ctx: ActionContext) -> AsyncGenerator[Workfl
327327
max_iterations = 100 # Safety limit
328328

329329
# Start external loop if configured
330+
# Build options for kwargs propagation to agent tools
331+
run_kwargs = ctx.run_kwargs
332+
options: dict[str, Any] | None = None
333+
if run_kwargs:
334+
# Merge caller-provided options to avoid duplicate keyword argument
335+
options = dict(run_kwargs.get("options") or {})
336+
options["additional_function_arguments"] = run_kwargs
337+
# Exclude 'options' from splat to avoid TypeError on duplicate keyword
338+
run_kwargs = {k: v for k, v in run_kwargs.items() if k != "options"}
339+
330340
while True:
331341
# Invoke the agent
332342
try:
@@ -337,7 +347,7 @@ async def handle_invoke_azure_agent(ctx: ActionContext) -> AsyncGenerator[Workfl
337347
updates: list[Any] = []
338348
tool_calls: list[Any] = []
339349

340-
async for chunk in agent.run(messages, stream=True):
350+
async for chunk in agent.run(messages, stream=True, options=options, **run_kwargs):
341351
updates.append(chunk)
342352

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

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

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

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

583+
# Build options for kwargs propagation to agent tools
584+
prompt_run_kwargs = ctx.run_kwargs
585+
prompt_options: dict[str, Any] | None = None
586+
if prompt_run_kwargs:
587+
# Merge caller-provided options to avoid duplicate keyword argument
588+
prompt_options = dict(prompt_run_kwargs.get("options") or {})
589+
prompt_options["additional_function_arguments"] = prompt_run_kwargs
590+
# Exclude 'options' from splat to avoid TypeError on duplicate keyword
591+
prompt_run_kwargs = {k: v for k, v in prompt_run_kwargs.items() if k != "options"}
592+
573593
# Invoke the agent
574594
try:
575595
if hasattr(agent, "run"):
576596
# Try streaming first
577597
try:
578598
updates: list[Any] = []
579599

580-
async for chunk in agent.run(messages, stream=True):
600+
async for chunk in agent.run(messages, stream=True, options=prompt_options, **prompt_run_kwargs):
581601
updates.append(chunk)
582602

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

608628
except TypeError:
609629
# Agent doesn't support streaming, fall back to non-streaming
610-
response = await agent.run(messages)
630+
response = await agent.run(messages, options=prompt_options, **prompt_run_kwargs)
611631
text = response.text
612632
response_messages = response.messages
613633

python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,13 @@
3737
WorkflowContext,
3838
)
3939
from agent_framework._workflows._state import State
40-
from powerfx import Engine
40+
41+
try:
42+
from powerfx import Engine
43+
except (ImportError, RuntimeError):
44+
# ImportError: powerfx package not installed
45+
# RuntimeError: .NET runtime not available or misconfigured
46+
Engine = None # type: ignore[assignment, misc]
4147

4248
if sys.version_info >= (3, 11):
4349
from typing import TypedDict # type: ignore # pragma: no cover
@@ -339,7 +345,8 @@ def eval(self, expression: str) -> Any:
339345
undefined variables (matching legacy fallback parser behavior).
340346
341347
Raises:
342-
ImportError: If the powerfx package is not installed.
348+
RuntimeError: If the powerfx package is not installed and the
349+
expression requires PowerFx evaluation.
343350
"""
344351
if not expression:
345352
return expression
@@ -363,6 +370,13 @@ def eval(self, expression: str) -> Any:
363370
# Replace them with their evaluated results before sending to PowerFx
364371
formula = self._preprocess_custom_functions(formula)
365372

373+
if Engine is None:
374+
raise RuntimeError(
375+
f"PowerFx is not available (dotnet runtime not installed). "
376+
f"Expression '={formula[:80]}' cannot be evaluated. "
377+
f"Install dotnet and the powerfx package for full PowerFx support."
378+
)
379+
366380
engine = Engine()
367381
symbols = self._to_powerfx_symbols()
368382
try:

python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -656,10 +656,22 @@ async def _invoke_agent_and_store_results(
656656
if isinstance(messages_for_agent, list) and messages_for_agent:
657657
_validate_conversation_history(messages_for_agent, agent_name)
658658

659+
# Retrieve kwargs passed to workflow.run() so they propagate to agent tools
660+
from agent_framework._workflows._const import WORKFLOW_RUN_KWARGS_KEY
661+
662+
run_kwargs: dict[str, Any] = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {})
663+
options: dict[str, Any] | None = None
664+
if run_kwargs:
665+
# Merge caller-provided options to avoid duplicate keyword argument
666+
options = dict(run_kwargs.get("options") or {})
667+
options["additional_function_arguments"] = run_kwargs
668+
# Exclude 'options' from splat to avoid TypeError on duplicate keyword
669+
run_kwargs = {k: v for k, v in run_kwargs.items() if k != "options"}
670+
659671
# Use run() method to get properly structured messages (including tool calls and results)
660672
# This is critical for multi-turn conversations where tool calls must be followed
661673
# by their results in the message history
662-
result: Any = await agent.run(messages_for_agent)
674+
result: Any = await agent.run(messages_for_agent, options=options, **run_kwargs)
663675
if hasattr(result, "text") and result.text:
664676
accumulated_response = str(result.text)
665677
if auto_send:

python/packages/declarative/agent_framework_declarative/_workflows/_handlers.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from __future__ import annotations
1111

1212
from collections.abc import AsyncGenerator, Callable
13-
from dataclasses import dataclass
13+
from dataclasses import dataclass, field
1414
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
1515

1616
from agent_framework import get_logger
@@ -44,6 +44,9 @@ class ActionContext:
4444
bindings: dict[str, Any]
4545
"""Function bindings for tool calls."""
4646

47+
run_kwargs: dict[str, Any] = field(default_factory=dict)
48+
"""Kwargs from workflow.run() to forward to agent invocations."""
49+
4750
@property
4851
def action_id(self) -> str | None:
4952
"""Get the action's unique identifier."""

python/packages/declarative/tests/test_declarative_loader.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
# Copyright (c) Microsoft. All rights reserved.
22

3+
import builtins
34
import sys
45
from pathlib import Path
56
from typing import Any
7+
from unittest.mock import AsyncMock, MagicMock, patch
68

79
import pytest
810
import yaml
@@ -905,3 +907,105 @@ def test_mcp_tool_with_remote_connection_with_endpoint(self):
905907

906908
# Verify project_connection_id is set from connection name
907909
assert mcp_tool.get("project_connection_id") == "my-oauth-connection"
910+
911+
912+
class TestProviderResponseFormat:
913+
"""response_format from outputSchema must be passed inside default_options."""
914+
915+
@staticmethod
916+
def _make_mock_prompt_agent(*, with_output_schema: bool = False) -> MagicMock:
917+
"""Create a mock PromptAgent to avoid serialization complexity."""
918+
mock_model = MagicMock()
919+
mock_model.id = "gpt-4"
920+
mock_model.connection = None
921+
922+
agent = MagicMock()
923+
agent.name = "test-agent"
924+
agent.description = "test"
925+
agent.instructions = "be helpful"
926+
agent.model = mock_model
927+
agent.tools = None
928+
929+
if with_output_schema:
930+
mock_schema = MagicMock()
931+
mock_schema.to_json_schema.return_value = {
932+
"type": "object",
933+
"properties": {"answer": {"type": "string"}},
934+
}
935+
agent.outputSchema = mock_schema
936+
else:
937+
agent.outputSchema = None
938+
939+
return agent
940+
941+
@staticmethod
942+
def _make_mock_provider() -> tuple[MagicMock, AsyncMock]:
943+
"""Create a mock provider class and its instance."""
944+
mock_agent = MagicMock()
945+
mock_provider_instance = AsyncMock()
946+
mock_provider_instance.create_agent = AsyncMock(return_value=mock_agent)
947+
mock_provider_class = MagicMock(return_value=mock_provider_instance)
948+
return mock_provider_class, mock_provider_instance
949+
950+
@pytest.mark.asyncio
951+
async def test_response_format_in_default_options(self):
952+
"""Provider.create_agent() should receive response_format inside default_options."""
953+
from agent_framework_declarative._loader import AgentFactory
954+
955+
prompt_agent = self._make_mock_prompt_agent(with_output_schema=True)
956+
mock_provider_class, mock_provider_instance = self._make_mock_provider()
957+
958+
mapping = {"package": "some_module", "name": "SomeProvider"}
959+
factory = AgentFactory()
960+
961+
original_import = builtins.__import__
962+
963+
def mock_import(name, *args, **kwargs):
964+
if name == "some_module":
965+
mod = MagicMock()
966+
mod.SomeProvider = mock_provider_class
967+
return mod
968+
return original_import(name, *args, **kwargs)
969+
970+
with (
971+
patch.object(builtins, "__import__", side_effect=mock_import),
972+
patch.object(factory, "_parse_tools", return_value=None),
973+
):
974+
await factory._create_agent_with_provider(prompt_agent, mapping)
975+
976+
mock_provider_instance.create_agent.assert_called_once()
977+
call_kwargs = mock_provider_instance.create_agent.call_args.kwargs
978+
979+
assert "response_format" not in call_kwargs
980+
default_options = call_kwargs.get("default_options")
981+
assert default_options is not None
982+
assert "response_format" in default_options
983+
984+
@pytest.mark.asyncio
985+
async def test_no_default_options_without_output_schema(self):
986+
"""When there's no outputSchema, default_options should be None."""
987+
from agent_framework_declarative._loader import AgentFactory
988+
989+
prompt_agent = self._make_mock_prompt_agent(with_output_schema=False)
990+
mock_provider_class, mock_provider_instance = self._make_mock_provider()
991+
992+
mapping = {"package": "some_module", "name": "SomeProvider"}
993+
factory = AgentFactory()
994+
995+
original_import = builtins.__import__
996+
997+
def mock_import(name, *args, **kwargs):
998+
if name == "some_module":
999+
mod = MagicMock()
1000+
mod.SomeProvider = mock_provider_class
1001+
return mod
1002+
return original_import(name, *args, **kwargs)
1003+
1004+
with (
1005+
patch.object(builtins, "__import__", side_effect=mock_import),
1006+
patch.object(factory, "_parse_tools", return_value=None),
1007+
):
1008+
await factory._create_agent_with_provider(prompt_agent, mapping)
1009+
1010+
call_kwargs = mock_provider_instance.create_agent.call_args.kwargs
1011+
assert call_kwargs.get("default_options") is None

0 commit comments

Comments
 (0)