[BREAKING] Python: Add InvokeFunctionTool action for declarative workflows (#3716)

* add(declarative): Declarative workflow InvokeFunctionTool feature

* Cleanup

* Address PR feedback

* Remove InvokeTool kind, consolidate to InvokeFunctionTool

* Fix sample locations

* pin azure-ai-projects to 2.0.0b3 due to breaking changes
This commit is contained in:
Evan Mattson
2026-02-25 07:54:35 +09:00
committed by GitHub
Unverified
parent f77f40b987
commit 40d2fac29c
23 changed files with 5089 additions and 682 deletions
@@ -961,6 +961,423 @@ tools:
assert mcp_tool.get("project_connection_id") == "my-oauth-connection"
class TestAgentFactoryFilePath:
"""Tests for AgentFactory file path operations."""
def test_create_agent_from_yaml_path_file_not_found(self, tmp_path):
"""Test that nonexistent file raises DeclarativeLoaderError."""
from agent_framework_declarative import AgentFactory
from agent_framework_declarative._loader import DeclarativeLoaderError
factory = AgentFactory()
with pytest.raises(DeclarativeLoaderError, match="YAML file not found"):
factory.create_agent_from_yaml_path(tmp_path / "nonexistent.yaml")
def test_create_agent_from_yaml_path_with_string_path(self, tmp_path):
"""Test create_agent_from_yaml_path accepts string path."""
from unittest.mock import MagicMock
from agent_framework_declarative import AgentFactory
yaml_file = tmp_path / "agent.yaml"
yaml_file.write_text("""
kind: Prompt
name: FileAgent
instructions: Test agent from file
""")
mock_client = MagicMock()
factory = AgentFactory(client=mock_client)
agent = factory.create_agent_from_yaml_path(str(yaml_file))
assert agent.name == "FileAgent"
def test_create_agent_from_yaml_path_with_path_object(self, tmp_path):
"""Test create_agent_from_yaml_path accepts Path object."""
from unittest.mock import MagicMock
from agent_framework_declarative import AgentFactory
yaml_file = tmp_path / "agent.yaml"
yaml_file.write_text("""
kind: Prompt
name: PathAgent
instructions: Test agent from Path
""")
mock_client = MagicMock()
factory = AgentFactory(client=mock_client)
agent = factory.create_agent_from_yaml_path(yaml_file)
assert agent.name == "PathAgent"
class TestAgentFactoryAsyncMethods:
"""Tests for AgentFactory async methods."""
@pytest.mark.asyncio
async def test_create_agent_from_yaml_path_async_file_not_found(self, tmp_path):
"""Test async version raises DeclarativeLoaderError for nonexistent file."""
from agent_framework_declarative import AgentFactory
from agent_framework_declarative._loader import DeclarativeLoaderError
factory = AgentFactory()
with pytest.raises(DeclarativeLoaderError, match="YAML file not found"):
await factory.create_agent_from_yaml_path_async(tmp_path / "nonexistent.yaml")
@pytest.mark.asyncio
async def test_create_agent_from_yaml_async_with_client(self):
"""Test async creation with pre-configured client."""
from unittest.mock import MagicMock
from agent_framework_declarative import AgentFactory
yaml_content = """
kind: Prompt
name: AsyncAgent
instructions: Test async agent
"""
mock_client = MagicMock()
factory = AgentFactory(client=mock_client)
agent = await factory.create_agent_from_yaml_async(yaml_content)
assert agent.name == "AsyncAgent"
@pytest.mark.asyncio
async def test_create_agent_from_dict_async_with_client(self):
"""Test async dict creation with pre-configured client."""
from unittest.mock import MagicMock
from agent_framework_declarative import AgentFactory
agent_def = {
"kind": "Prompt",
"name": "AsyncDictAgent",
"instructions": "Test async dict agent",
}
mock_client = MagicMock()
factory = AgentFactory(client=mock_client)
agent = await factory.create_agent_from_dict_async(agent_def)
assert agent.name == "AsyncDictAgent"
@pytest.mark.asyncio
async def test_create_agent_from_dict_async_invalid_kind_raises(self):
"""Test that async version also raises for non-PromptAgent."""
from agent_framework_declarative import AgentFactory
from agent_framework_declarative._loader import DeclarativeLoaderError
agent_def = {
"kind": "Resource",
"name": "NotAnAgent",
}
factory = AgentFactory()
with pytest.raises(DeclarativeLoaderError, match="Only definitions for a PromptAgent are supported"):
await factory.create_agent_from_dict_async(agent_def)
@pytest.mark.asyncio
async def test_create_agent_from_yaml_path_async_with_string_path(self, tmp_path):
"""Test async version accepts string path."""
from unittest.mock import MagicMock
from agent_framework_declarative import AgentFactory
yaml_file = tmp_path / "async_agent.yaml"
yaml_file.write_text("""
kind: Prompt
name: AsyncPathAgent
instructions: Test async path agent
""")
mock_client = MagicMock()
factory = AgentFactory(client=mock_client)
agent = await factory.create_agent_from_yaml_path_async(str(yaml_file))
assert agent.name == "AsyncPathAgent"
class TestAgentFactoryProviderLookup:
"""Tests for provider configuration lookup."""
def test_provider_lookup_error_for_unknown_provider(self):
"""Test that unknown provider raises ProviderLookupError."""
from agent_framework_declarative import AgentFactory
from agent_framework_declarative._loader import ProviderLookupError
yaml_content = """
kind: Prompt
name: TestAgent
instructions: Test agent
model:
id: test-model
provider: UnknownProvider
apiType: UnknownApiType
"""
factory = AgentFactory()
with pytest.raises(ProviderLookupError, match="Unsupported provider type"):
factory.create_agent_from_yaml(yaml_content)
def test_additional_mappings_override_default(self):
"""Test that additional_mappings can extend provider configurations."""
from agent_framework_declarative import AgentFactory
# Define a custom provider mapping
custom_mappings = {
"CustomProvider.Chat": {
"package": "agent_framework.openai",
"name": "OpenAIChatClient",
"model_id_field": "model_id",
},
}
factory = AgentFactory(additional_mappings=custom_mappings)
# The custom mapping should be available
assert "CustomProvider.Chat" in factory.additional_mappings
class TestAgentFactoryConnectionHandling:
"""Tests for connection handling in AgentFactory."""
def test_reference_connection_requires_connections_dict(self):
"""Test that ReferenceConnection without connections dict raises."""
from agent_framework_declarative import AgentFactory
yaml_content = """
kind: Prompt
name: TestAgent
instructions: Test agent
model:
id: gpt-4
provider: OpenAI
apiType: Chat
connection:
kind: reference
name: my-connection
"""
factory = AgentFactory() # No connections provided
with pytest.raises(ValueError, match="Connections must be provided to resolve ReferenceConnection"):
factory.create_agent_from_yaml(yaml_content)
def test_reference_connection_not_found_raises(self):
"""Test that missing ReferenceConnection raises."""
from agent_framework_declarative import AgentFactory
yaml_content = """
kind: Prompt
name: TestAgent
instructions: Test agent
model:
id: gpt-4
provider: OpenAI
apiType: Chat
connection:
kind: reference
name: missing-connection
"""
factory = AgentFactory(connections={"other-connection": "value"})
with pytest.raises(ValueError, match="not found in provided connections"):
factory.create_agent_from_yaml(yaml_content)
def test_model_without_id_uses_provided_client(self):
"""Test that model without id uses the provided chat_client."""
from unittest.mock import MagicMock
from agent_framework_declarative import AgentFactory
yaml_content = """
kind: Prompt
name: TestAgent
instructions: Test agent
model:
provider: OpenAI
"""
mock_client = MagicMock()
factory = AgentFactory(client=mock_client)
agent = factory.create_agent_from_yaml(yaml_content)
assert agent is not None
def test_model_without_id_and_no_client_raises(self):
"""Test that model without id and no client raises."""
from agent_framework_declarative import AgentFactory
from agent_framework_declarative._loader import DeclarativeLoaderError
yaml_content = """
kind: Prompt
name: TestAgent
instructions: Test agent
model:
provider: OpenAI
"""
factory = AgentFactory() # No chat_client
with pytest.raises(DeclarativeLoaderError, match="ChatClient must be provided"):
factory.create_agent_from_yaml(yaml_content)
class TestAgentFactoryChatOptions:
"""Tests for chat options parsing."""
def test_parse_chat_options_with_all_fields(self):
"""Test parsing all ModelOptions fields into chat options dict."""
from agent_framework_declarative._loader import AgentFactory
from agent_framework_declarative._models import Model, ModelOptions
factory = AgentFactory()
# Create a Model with all options set
options = ModelOptions(
temperature=0.7,
maxOutputTokens=1000,
topP=0.9,
frequencyPenalty=0.5,
presencePenalty=0.3,
seed=42,
stopSequences=["STOP", "END"],
allowMultipleToolCalls=True,
)
options.additionalProperties["chatToolMode"] = "auto"
model = Model(id="gpt-4", options=options)
# Parse the options
chat_options = factory._parse_chat_options(model)
# Verify all options are parsed correctly
assert chat_options.get("temperature") == 0.7
assert chat_options.get("max_tokens") == 1000
assert chat_options.get("top_p") == 0.9
assert chat_options.get("frequency_penalty") == 0.5
assert chat_options.get("presence_penalty") == 0.3
assert chat_options.get("seed") == 42
assert chat_options.get("stop") == ["STOP", "END"]
assert chat_options.get("allow_multiple_tool_calls") is True
assert chat_options.get("tool_choice") == "auto"
def test_parse_chat_options_empty_model(self):
"""Test that missing model options returns empty dict."""
from agent_framework_declarative._loader import AgentFactory
factory = AgentFactory()
result = factory._parse_chat_options(None)
assert result == {}
def test_parse_chat_options_with_additional_properties(self):
"""Test that additional properties are passed through."""
from agent_framework_declarative._loader import AgentFactory
from agent_framework_declarative._models import Model, ModelOptions
factory = AgentFactory()
# Create a Model with additional properties
options = ModelOptions(temperature=0.5)
options.additionalProperties["customOption"] = "customValue"
model = Model(id="gpt-4", options=options)
# Parse the options
chat_options = factory._parse_chat_options(model)
# Verify additional properties are preserved
assert "additional_chat_options" in chat_options
assert chat_options["additional_chat_options"].get("customOption") == "customValue"
class TestAgentFactoryToolParsing:
"""Tests for tool parsing edge cases."""
def test_parse_tools_returns_none_for_empty_list(self):
"""Test that empty tools list returns None."""
from agent_framework_declarative._loader import AgentFactory
factory = AgentFactory()
result = factory._parse_tools(None)
assert result is None
result = factory._parse_tools([])
assert result is None
def test_parse_function_tool_with_bindings(self):
"""Test parsing FunctionTool with bindings."""
from unittest.mock import MagicMock
from agent_framework_declarative import AgentFactory
yaml_content = """
kind: Prompt
name: TestAgent
instructions: Test agent
tools:
- kind: function
name: my_function
description: A test function
bindings:
- name: my_binding
"""
def my_function():
return "result"
mock_client = MagicMock()
factory = AgentFactory(client=mock_client, bindings={"my_binding": my_function})
agent = factory.create_agent_from_yaml(yaml_content)
# Should have parsed the tool with binding
tools = agent.default_options.get("tools", [])
assert len(tools) == 1
def test_parse_file_search_tool_with_all_options(self):
"""Test parsing FileSearchTool with ranker and filters."""
from unittest.mock import MagicMock
from agent_framework_declarative import AgentFactory
yaml_content = """
kind: Prompt
name: TestAgent
instructions: Test agent
tools:
- kind: file_search
name: search
description: Search files
vectorStoreIds:
- vs_123
ranker: semantic
scoreThreshold: 0.8
maximumResultCount: 10
filters:
type: document
"""
mock_client = MagicMock()
factory = AgentFactory(client=mock_client)
agent = factory.create_agent_from_yaml(yaml_content)
# Verify a file search tool was parsed
tools = agent.default_options.get("tools", [])
assert len(tools) == 1
def test_parse_unsupported_tool_kind_raises(self):
"""Test that unsupported tool kind raises ValueError."""
from agent_framework_declarative._loader import AgentFactory
from agent_framework_declarative._models import CustomTool
factory = AgentFactory()
custom_tool = CustomTool(kind="custom", name="test")
with pytest.raises(ValueError, match="Unsupported tool kind"):
factory._parse_tool(custom_tool)
class TestProviderResponseFormat:
"""response_format from outputSchema must be passed inside default_options."""
File diff suppressed because it is too large Load Diff
@@ -21,6 +21,15 @@ from agent_framework_declarative._workflows._declarative_base import (
LoopIterationResult,
)
try:
import powerfx # noqa: F401
_powerfx_available = True
except (ImportError, RuntimeError):
_powerfx_available = False
_requires_powerfx = pytest.mark.skipif(not _powerfx_available, reason="PowerFx engine not available")
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@@ -250,6 +259,7 @@ class TestDeclarativeWorkflowStateExtended:
result = state.eval([1, 2, 3]) # type: ignore[arg-type]
assert result == [1, 2, 3]
@_requires_powerfx
async def test_eval_simple_and_operator(self, mock_state):
"""Test simple And operator evaluation."""
state = DeclarativeWorkflowState(mock_state)
@@ -264,6 +274,7 @@ class TestDeclarativeWorkflowStateExtended:
result = state.eval("=Local.a And Local.b")
assert result is True
@_requires_powerfx
async def test_eval_simple_or_operator(self, mock_state):
"""Test simple Or operator evaluation."""
state = DeclarativeWorkflowState(mock_state)
@@ -278,6 +289,7 @@ class TestDeclarativeWorkflowStateExtended:
result = state.eval("=Local.a Or Local.b")
assert result is False
@_requires_powerfx
async def test_eval_negation(self, mock_state):
"""Test negation (!) evaluation."""
state = DeclarativeWorkflowState(mock_state)
@@ -287,6 +299,7 @@ class TestDeclarativeWorkflowStateExtended:
result = state.eval("=!Local.flag")
assert result is False
@_requires_powerfx
async def test_eval_not_function(self, mock_state):
"""Test Not() function evaluation."""
state = DeclarativeWorkflowState(mock_state)
@@ -296,6 +309,7 @@ class TestDeclarativeWorkflowStateExtended:
result = state.eval("=Not(Local.flag)")
assert result is False
@_requires_powerfx
async def test_eval_comparison_operators(self, mock_state):
"""Test comparison operators."""
state = DeclarativeWorkflowState(mock_state)
@@ -310,6 +324,7 @@ class TestDeclarativeWorkflowStateExtended:
assert state.eval("=Local.x <> Local.y") is True
assert state.eval("=Local.x = 5") is True
@_requires_powerfx
async def test_eval_arithmetic_operators(self, mock_state):
"""Test arithmetic operators."""
state = DeclarativeWorkflowState(mock_state)
@@ -322,6 +337,7 @@ class TestDeclarativeWorkflowStateExtended:
assert state.eval("=Local.x * Local.y") == 30
assert state.eval("=Local.x / Local.y") == pytest.approx(3.333, rel=0.01)
@_requires_powerfx
async def test_eval_string_literal(self, mock_state):
"""Test string literal evaluation."""
state = DeclarativeWorkflowState(mock_state)
@@ -330,6 +346,7 @@ class TestDeclarativeWorkflowStateExtended:
result = state.eval('="hello world"')
assert result == "hello world"
@_requires_powerfx
async def test_eval_float_literal(self, mock_state):
"""Test float literal evaluation."""
from decimal import Decimal
@@ -341,6 +358,7 @@ class TestDeclarativeWorkflowStateExtended:
# Accepts both float (Python fallback) and Decimal (pythonnet/PowerFx)
assert result == 3.14 or result == Decimal("3.14")
@_requires_powerfx
async def test_eval_variable_reference_with_namespace_mappings(self, mock_state):
"""Test variable reference with PowerFx symbols."""
state = DeclarativeWorkflowState(mock_state)
@@ -355,6 +373,7 @@ class TestDeclarativeWorkflowStateExtended:
result = state.eval("=Workflow.Inputs.query")
assert result == "test"
@_requires_powerfx
async def test_eval_if_expression_with_dict(self, mock_state):
"""Test eval_if_expression recursively evaluates dicts."""
state = DeclarativeWorkflowState(mock_state)
@@ -364,6 +383,7 @@ class TestDeclarativeWorkflowStateExtended:
result = state.eval_if_expression({"greeting": "=Local.name", "static": "hello"})
assert result == {"greeting": "Alice", "static": "hello"}
@_requires_powerfx
async def test_eval_if_expression_with_list(self, mock_state):
"""Test eval_if_expression recursively evaluates lists."""
state = DeclarativeWorkflowState(mock_state)
@@ -449,6 +469,7 @@ class TestBasicExecutorsCoverage:
result = state.get("Local.nested")
assert result == 42
@_requires_powerfx
async def test_set_text_variable_executor(self, mock_context, mock_state):
"""Test SetTextVariableExecutor."""
from agent_framework_declarative._workflows._executors_basic import (
@@ -591,6 +612,7 @@ class TestBasicExecutorsCoverage:
mock_context.yield_output.assert_called_once_with("Plain text message")
@_requires_powerfx
async def test_send_activity_with_expression(self, mock_context, mock_state):
"""Test SendActivityExecutor evaluates expressions."""
from agent_framework_declarative._workflows._executors_basic import (
@@ -909,6 +931,7 @@ class TestAgentExecutorsCoverage:
assert result_prop == "Local.result"
assert auto_send is False
@_requires_powerfx
async def test_agent_executor_build_input_text_from_string_messages(self, mock_context, mock_state):
"""Test _build_input_text with string messages expression."""
from agent_framework_declarative._workflows._executors_agents import (
@@ -925,6 +948,7 @@ class TestAgentExecutorsCoverage:
input_text = await executor._build_input_text(state, {}, "=Local.userInput")
assert input_text == "Hello agent!"
@_requires_powerfx
async def test_agent_executor_build_input_text_from_message_list(self, mock_context, mock_state):
"""Test _build_input_text extracts text from message list."""
from agent_framework_declarative._workflows._executors_agents import (
@@ -948,6 +972,7 @@ class TestAgentExecutorsCoverage:
input_text = await executor._build_input_text(state, {}, "=Conversation.messages")
assert input_text == "Last message"
@_requires_powerfx
async def test_agent_executor_build_input_text_from_message_with_text_attr(self, mock_context, mock_state):
"""Test _build_input_text extracts text from message with text attribute."""
from agent_framework_declarative._workflows._executors_agents import (
@@ -1120,83 +1145,6 @@ class TestAgentExecutorsCoverage:
parsed = state.get("Local.Parsed")
assert parsed == {"status": "ok", "count": 42}
async def test_invoke_tool_executor_not_found(self, mock_context, mock_state):
"""Test InvokeToolExecutor when tool not found."""
from agent_framework_declarative._workflows._executors_agents import (
InvokeToolExecutor,
)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
action_def = {
"kind": "InvokeTool",
"tool": "MissingTool",
"resultProperty": "Local.result",
}
executor = InvokeToolExecutor(action_def)
await executor.handle_action(ActionTrigger(), mock_context)
result = state.get("Local.result")
assert result == {"error": "Tool 'MissingTool' not found in registry"}
async def test_invoke_tool_executor_sync_tool(self, mock_context, mock_state):
"""Test InvokeToolExecutor with synchronous tool."""
from agent_framework_declarative._workflows._executors_agents import (
TOOL_REGISTRY_KEY,
InvokeToolExecutor,
)
def my_tool(x: int, y: int) -> int:
return x + y
mock_state._data[TOOL_REGISTRY_KEY] = {"add": my_tool}
state = DeclarativeWorkflowState(mock_state)
state.initialize()
action_def = {
"kind": "InvokeTool",
"tool": "add",
"parameters": {"x": 5, "y": 3},
"resultProperty": "Local.result",
}
executor = InvokeToolExecutor(action_def)
await executor.handle_action(ActionTrigger(), mock_context)
result = state.get("Local.result")
assert result == 8
async def test_invoke_tool_executor_async_tool(self, mock_context, mock_state):
"""Test InvokeToolExecutor with asynchronous tool."""
from agent_framework_declarative._workflows._executors_agents import (
TOOL_REGISTRY_KEY,
InvokeToolExecutor,
)
async def my_async_tool(input: str) -> str:
return f"Processed: {input}"
mock_state._data[TOOL_REGISTRY_KEY] = {"process": my_async_tool}
state = DeclarativeWorkflowState(mock_state)
state.initialize()
action_def = {
"kind": "InvokeTool",
"tool": "process",
"input": "test data",
"resultProperty": "Local.result",
}
executor = InvokeToolExecutor(action_def)
await executor.handle_action(ActionTrigger(), mock_context)
result = state.get("Local.result")
assert result == "Processed: test data"
# ---------------------------------------------------------------------------
# Control Flow Executors Tests - Additional coverage
@@ -1206,6 +1154,7 @@ class TestAgentExecutorsCoverage:
class TestControlFlowCoverage:
"""Tests for control flow executors covering uncovered code paths."""
@_requires_powerfx
async def test_foreach_with_source_alias(self, mock_context, mock_state):
"""Test ForeachInitExecutor with 'source' alias (interpreter mode)."""
from agent_framework_declarative._workflows._executors_control_flow import (
@@ -1268,6 +1217,7 @@ class TestControlFlowCoverage:
assert msg.current_index == 1
assert msg.current_item == "b"
@_requires_powerfx
async def test_switch_evaluator_with_value_cases(self, mock_context, mock_state):
"""Test SwitchEvaluatorExecutor with value/cases schema."""
from agent_framework_declarative._workflows._executors_control_flow import (
@@ -1295,6 +1245,7 @@ class TestControlFlowCoverage:
assert msg.matched is True
assert msg.branch_index == 1 # Second case matched
@_requires_powerfx
async def test_switch_evaluator_default_case(self, mock_context, mock_state):
"""Test SwitchEvaluatorExecutor falls through to default."""
from agent_framework_declarative._workflows._executors_control_flow import (
@@ -1554,6 +1505,7 @@ class TestControlFlowCoverage:
# Should NOT send any message
mock_context.send_message.assert_not_called()
@_requires_powerfx
async def test_condition_group_evaluator_first_match(self, mock_context, mock_state):
"""Test ConditionGroupEvaluatorExecutor returns first match."""
from agent_framework_declarative._workflows._executors_control_flow import (
@@ -1579,6 +1531,7 @@ class TestControlFlowCoverage:
assert msg.matched is True
assert msg.branch_index == 1 # Second condition (x > 5) is first match
@_requires_powerfx
async def test_condition_group_evaluator_no_match(self, mock_context, mock_state):
"""Test ConditionGroupEvaluatorExecutor with no matches."""
from agent_framework_declarative._workflows._executors_control_flow import (
@@ -1603,6 +1556,7 @@ class TestControlFlowCoverage:
assert msg.matched is False
assert msg.branch_index == -1
@_requires_powerfx
async def test_condition_group_evaluator_boolean_true_condition(self, mock_context, mock_state):
"""Test ConditionGroupEvaluatorExecutor with boolean True condition."""
from agent_framework_declarative._workflows._executors_control_flow import (
@@ -1626,6 +1580,7 @@ class TestControlFlowCoverage:
assert msg.matched is True
assert msg.branch_index == 1
@_requires_powerfx
async def test_if_condition_evaluator_true(self, mock_context, mock_state):
"""Test IfConditionEvaluatorExecutor with true condition."""
from agent_framework_declarative._workflows._executors_control_flow import (
@@ -1646,6 +1601,7 @@ class TestControlFlowCoverage:
assert msg.matched is True
assert msg.branch_index == 0 # Then branch
@_requires_powerfx
async def test_if_condition_evaluator_false(self, mock_context, mock_state):
"""Test IfConditionEvaluatorExecutor with false condition."""
from agent_framework_declarative._workflows._executors_control_flow import (
@@ -1894,6 +1850,7 @@ class TestHumanInputExecutorsCoverage:
class TestAgentExternalLoopCoverage:
"""Tests for agent executor external loop handling."""
@_requires_powerfx
async def test_agent_executor_with_external_loop(self, mock_context, mock_state):
"""Test agent executor with external loop that triggers."""
from unittest.mock import patch
@@ -1996,39 +1953,13 @@ class TestAgentExternalLoopCoverage:
result = state.get("Local.result")
assert result == "Direct string response"
async def test_invoke_tool_with_error(self, mock_context, mock_state):
"""Test InvokeToolExecutor handles tool errors."""
from agent_framework_declarative._workflows._executors_agents import (
TOOL_REGISTRY_KEY,
InvokeToolExecutor,
)
def failing_tool(**kwargs):
raise ValueError("Tool error")
mock_state._data[TOOL_REGISTRY_KEY] = {"bad_tool": failing_tool}
state = DeclarativeWorkflowState(mock_state)
state.initialize()
action_def = {
"kind": "InvokeTool",
"tool": "bad_tool",
"resultProperty": "Local.result",
}
executor = InvokeToolExecutor(action_def)
await executor.handle_action(ActionTrigger(), mock_context)
result = state.get("Local.result")
assert result == {"error": "Tool error"}
# ---------------------------------------------------------------------------
# PowerFx Functions Coverage
# ---------------------------------------------------------------------------
@_requires_powerfx
class TestPowerFxFunctionsCoverage:
"""Tests for PowerFx function evaluation coverage."""
@@ -2784,6 +2715,7 @@ class TestBuilderValidation:
assert "activity" in str(exc_info.value)
@_requires_powerfx
class TestExpressionEdgeCases:
"""Tests for expression evaluation edge cases."""
@@ -2808,6 +2740,7 @@ class TestExpressionEdgeCases:
assert result == 42
@_requires_powerfx
class TestLongMessageTextHandling:
"""Tests for handling long MessageText results that exceed PowerFx limits."""
@@ -7,7 +7,16 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from agent_framework_declarative._workflows import (
try:
import powerfx # noqa: F401
_powerfx_available = True
except (ImportError, RuntimeError):
_powerfx_available = False
_requires_powerfx = pytest.mark.skipif(not _powerfx_available, reason="PowerFx engine not available")
from agent_framework_declarative._workflows import ( # noqa: E402
ALL_ACTION_EXECUTORS,
DECLARATIVE_STATE_KEY,
ActionComplete,
@@ -99,6 +108,7 @@ class TestDeclarativeWorkflowState:
result = state.get("Local.items")
assert result == ["first", "second"]
@_requires_powerfx
@pytest.mark.asyncio
async def test_eval_expression(self, mock_state):
"""Test evaluating expressions."""
@@ -196,6 +206,7 @@ class TestDeclarativeActionExecutor:
# Note: ConditionEvaluatorExecutor tests removed - conditions are now evaluated on edges
@_requires_powerfx
async def test_foreach_init_with_items(self, mock_context, mock_state):
"""Test ForeachInitExecutor with items."""
state = DeclarativeWorkflowState(mock_state)
@@ -529,6 +540,7 @@ class TestHumanInputExecutors:
assert "continue" in request.message.lower()
@_requires_powerfx
class TestParseValueExecutor:
"""Tests for the ParseValue action executor."""
@@ -9,13 +9,27 @@ These tests verify:
- Pause/resume capabilities
"""
import sys
import pytest
from agent_framework_declarative._workflows import (
try:
import powerfx # noqa: F401
_powerfx_available = True
except (ImportError, RuntimeError):
_powerfx_available = False
pytestmark = pytest.mark.skipif(
not _powerfx_available or sys.version_info >= (3, 14),
reason="PowerFx engine not available (requires dotnet runtime)",
)
from agent_framework_declarative._workflows import ( # noqa: E402
ActionTrigger,
DeclarativeWorkflowBuilder,
)
from agent_framework_declarative._workflows._factory import WorkflowFactory
from agent_framework_declarative._workflows._factory import WorkflowFactory # noqa: E402
class TestGraphBasedWorkflowExecution:
@@ -237,6 +237,444 @@ class TestCustomFunctionsRegistry:
"Lower",
"Concat",
"Search",
"If",
"Or",
"And",
"Not",
"AgentMessage",
"ForAll",
]
for name in expected:
assert name in CUSTOM_FUNCTIONS
class TestMessageTextEdgeCases:
"""Additional tests for message_text edge cases."""
def test_message_text_dict_with_text_attr_content(self):
"""Test message with content that has text attribute."""
class ContentWithText: # noqa: B903
def __init__(self, text: str):
self.text = text
msg = {"role": "assistant", "content": ContentWithText("Hello from text attr")}
assert message_text(msg) == "Hello from text attr"
def test_message_text_dict_content_non_string(self):
"""Test message with non-string content."""
msg = {"role": "assistant", "content": 42}
assert message_text(msg) == "42"
def test_message_text_list_with_string_items(self):
"""Test message_text with list of strings."""
result = message_text(["Hello", "World"])
assert result == "Hello World"
def test_message_text_list_with_content_objects(self):
"""Test message_text with list items having content attribute."""
class MessageObj: # noqa: B903
def __init__(self, content: str):
self.content = content
msgs = [MessageObj("Hello"), MessageObj("World")]
result = message_text(msgs)
assert result == "Hello World"
def test_message_text_list_with_content_text_attr(self):
"""Test message_text with content having text attribute."""
class ContentWithText: # noqa: B903
def __init__(self, text: str):
self.text = text
class MessageObj:
def __init__(self, content):
self.content = content
msgs = [MessageObj(ContentWithText("Part1")), MessageObj(ContentWithText("Part2"))]
result = message_text(msgs)
assert result == "Part1 Part2"
def test_message_text_list_with_non_string_content(self):
"""Test message_text with non-string content in dicts."""
msgs = [{"content": 123}, {"content": 456}]
result = message_text(msgs)
assert result == "123 456"
def test_message_text_object_with_text_attr(self):
"""Test message_text with object having text attribute."""
class ObjWithText:
text = "Direct text"
result = message_text(ObjWithText())
assert result == "Direct text"
def test_message_text_object_with_content_attr(self):
"""Test message_text with object having content attribute."""
class ObjWithContent:
content = "Direct content"
result = message_text(ObjWithContent())
assert result == "Direct content"
def test_message_text_object_with_non_string_content(self):
"""Test message_text with object having non-string content."""
class ObjWithContent:
content = None
result = message_text(ObjWithContent())
assert result == ""
def test_message_text_list_with_empty_content_object(self):
"""Test message with content object that evaluates to empty."""
class MessageObj:
content = None
result = message_text([MessageObj()])
assert result == ""
class TestAgentMessage:
"""Tests for agent_message function."""
def test_agent_message_creates_dict(self):
"""Test that AgentMessage creates correct dict."""
from agent_framework_declarative._workflows._powerfx_functions import agent_message
msg = agent_message("Hello")
assert msg == {"role": "assistant", "content": "Hello"}
def test_agent_message_with_none(self):
"""Test AgentMessage with None."""
from agent_framework_declarative._workflows._powerfx_functions import agent_message
msg = agent_message(None)
assert msg == {"role": "assistant", "content": ""}
class TestIfFunc:
"""Tests for if_func conditional function."""
def test_if_true_condition(self):
"""Test If with true condition."""
from agent_framework_declarative._workflows._powerfx_functions import if_func
assert if_func(True, "yes", "no") == "yes"
def test_if_false_condition(self):
"""Test If with false condition."""
from agent_framework_declarative._workflows._powerfx_functions import if_func
assert if_func(False, "yes", "no") == "no"
def test_if_truthy_value(self):
"""Test If with truthy value."""
from agent_framework_declarative._workflows._powerfx_functions import if_func
assert if_func(1, "yes", "no") == "yes"
assert if_func("non-empty", "yes", "no") == "yes"
def test_if_falsy_value(self):
"""Test If with falsy value."""
from agent_framework_declarative._workflows._powerfx_functions import if_func
assert if_func(0, "yes", "no") == "no"
assert if_func("", "yes", "no") == "no"
assert if_func(None, "yes", "no") == "no"
def test_if_no_false_value(self):
"""Test If with no false value defaults to None."""
from agent_framework_declarative._workflows._powerfx_functions import if_func
assert if_func(False, "yes") is None
class TestOrFunc:
"""Tests for or_func function."""
def test_or_all_false(self):
"""Test Or with all false values."""
from agent_framework_declarative._workflows._powerfx_functions import or_func
assert or_func(False, False, False) is False
def test_or_one_true(self):
"""Test Or with one true value."""
from agent_framework_declarative._workflows._powerfx_functions import or_func
assert or_func(False, True, False) is True
def test_or_all_true(self):
"""Test Or with all true values."""
from agent_framework_declarative._workflows._powerfx_functions import or_func
assert or_func(True, True, True) is True
def test_or_empty(self):
"""Test Or with no arguments."""
from agent_framework_declarative._workflows._powerfx_functions import or_func
assert or_func() is False
class TestAndFunc:
"""Tests for and_func function."""
def test_and_all_true(self):
"""Test And with all true values."""
from agent_framework_declarative._workflows._powerfx_functions import and_func
assert and_func(True, True, True) is True
def test_and_one_false(self):
"""Test And with one false value."""
from agent_framework_declarative._workflows._powerfx_functions import and_func
assert and_func(True, False, True) is False
def test_and_all_false(self):
"""Test And with all false values."""
from agent_framework_declarative._workflows._powerfx_functions import and_func
assert and_func(False, False, False) is False
def test_and_empty(self):
"""Test And with no arguments."""
from agent_framework_declarative._workflows._powerfx_functions import and_func
assert and_func() is True
class TestNotFunc:
"""Tests for not_func function."""
def test_not_true(self):
"""Test Not with true."""
from agent_framework_declarative._workflows._powerfx_functions import not_func
assert not_func(True) is False
def test_not_false(self):
"""Test Not with false."""
from agent_framework_declarative._workflows._powerfx_functions import not_func
assert not_func(False) is True
def test_not_truthy(self):
"""Test Not with truthy values."""
from agent_framework_declarative._workflows._powerfx_functions import not_func
assert not_func(1) is False
assert not_func("text") is False
def test_not_falsy(self):
"""Test Not with falsy values."""
from agent_framework_declarative._workflows._powerfx_functions import not_func
assert not_func(0) is True
assert not_func("") is True
assert not_func(None) is True
class TestIsBlankEdgeCases:
"""Additional tests for is_blank edge cases."""
def test_is_blank_empty_dict(self):
"""Test that empty dict is blank."""
assert is_blank({}) is True
def test_is_blank_non_empty_dict(self):
"""Test that non-empty dict is not blank."""
assert is_blank({"key": "value"}) is False
class TestCountRowsEdgeCases:
"""Additional tests for count_rows edge cases."""
def test_count_rows_dict(self):
"""Test counting dict items."""
assert count_rows({"a": 1, "b": 2, "c": 3}) == 3
def test_count_rows_tuple(self):
"""Test counting tuple items."""
assert count_rows((1, 2, 3, 4)) == 4
def test_count_rows_non_iterable(self):
"""Test counting non-iterable returns 0."""
assert count_rows(42) == 0
assert count_rows("string") == 0
class TestFirstLastEdgeCases:
"""Additional tests for first/last edge cases."""
def test_first_none(self):
"""Test first with None."""
assert first(None) is None
def test_last_none(self):
"""Test last with None."""
assert last(None) is None
def test_first_tuple(self):
"""Test first with tuple."""
assert first((1, 2, 3)) == 1
def test_last_tuple(self):
"""Test last with tuple."""
assert last((1, 2, 3)) == 3
class TestFindEdgeCases:
"""Additional tests for find edge cases."""
def test_find_none_substring(self):
"""Test find with None substring."""
assert find(None, "text") is None
def test_find_none_text(self):
"""Test find with None text."""
assert find("sub", None) is None
def test_find_both_none(self):
"""Test find with both None."""
assert find(None, None) is None
class TestLowerEdgeCases:
"""Additional tests for lower edge cases."""
def test_lower_none(self):
"""Test lower with None."""
assert lower(None) == ""
class TestConcatStrings:
"""Tests for concat_strings function."""
def test_concat_strings_basic(self):
"""Test basic string concatenation."""
from agent_framework_declarative._workflows._powerfx_functions import concat_strings
assert concat_strings("Hello", " ", "World") == "Hello World"
def test_concat_strings_with_none(self):
"""Test concat with None values."""
from agent_framework_declarative._workflows._powerfx_functions import concat_strings
assert concat_strings("Hello", None, "World") == "HelloWorld"
def test_concat_strings_empty(self):
"""Test concat with no arguments."""
from agent_framework_declarative._workflows._powerfx_functions import concat_strings
assert concat_strings() == ""
class TestConcatTextEdgeCases:
"""Additional tests for concat_text edge cases."""
def test_concat_text_none(self):
"""Test concat_text with None."""
assert concat_text(None) == ""
def test_concat_text_non_list(self):
"""Test concat_text with non-list."""
assert concat_text("single value") == "single value"
def test_concat_text_with_field_attr(self):
"""Test concat_text with field as object attribute."""
class Item: # noqa: B903
def __init__(self, name: str):
self.name = name
items = [Item("Alice"), Item("Bob")]
assert concat_text(items, field="name", separator=", ") == "Alice, Bob"
def test_concat_text_with_none_values(self):
"""Test concat_text with None values in list."""
items = [{"name": "Alice"}, {"name": None}, {"name": "Bob"}]
result = concat_text(items, field="name", separator=", ")
assert result == "Alice, , Bob"
class TestForAll:
"""Tests for for_all function."""
def test_for_all_with_list_of_dicts(self):
"""Test ForAll with list of dictionaries."""
from agent_framework_declarative._workflows._powerfx_functions import for_all
items = [{"name": "Alice"}, {"name": "Bob"}]
result = for_all(items, "expression")
assert result == items
def test_for_all_with_non_dict_items(self):
"""Test ForAll with non-dict items."""
from agent_framework_declarative._workflows._powerfx_functions import for_all
items = [1, 2, 3]
result = for_all(items, "expression")
assert result == [1, 2, 3]
def test_for_all_with_none(self):
"""Test ForAll with None."""
from agent_framework_declarative._workflows._powerfx_functions import for_all
assert for_all(None, "expression") == []
def test_for_all_with_non_list(self):
"""Test ForAll with non-list."""
from agent_framework_declarative._workflows._powerfx_functions import for_all
assert for_all("not a list", "expression") == []
def test_for_all_empty_list(self):
"""Test ForAll with empty list."""
from agent_framework_declarative._workflows._powerfx_functions import for_all
assert for_all([], "expression") == []
class TestSearchTableEdgeCases:
"""Additional tests for search_table edge cases."""
def test_search_table_none(self):
"""Test search_table with None."""
assert search_table(None, "value", "column") == []
def test_search_table_non_list(self):
"""Test search_table with non-list."""
assert search_table("not a list", "value", "column") == []
def test_search_table_with_object_attr(self):
"""Test search_table with object attributes."""
class Item: # noqa: B903
def __init__(self, name: str):
self.name = name
items = [Item("Alice"), Item("Bob"), Item("Charlie")]
result = search_table(items, "Bob", "name")
assert len(result) == 1
assert result[0].name == "Bob"
def test_search_table_no_matching_column(self):
"""Test search_table when items don't have the column."""
items = [{"other": "value"}]
result = search_table(items, "value", "name")
assert result == []
def test_search_table_empty_value(self):
"""Test search_table with empty search value."""
items = [{"name": "Alice"}, {"name": "Bob"}]
result = search_table(items, "", "name")
# Empty string matches everything
assert len(result) == 2
@@ -20,7 +20,16 @@ from unittest.mock import MagicMock
import pytest
from agent_framework_declarative._workflows._declarative_base import (
try:
import powerfx # noqa: F401
_powerfx_available = True
except (ImportError, RuntimeError):
_powerfx_available = False
pytestmark = pytest.mark.skipif(not _powerfx_available, reason="PowerFx engine not available")
from agent_framework_declarative._workflows._declarative_base import ( # noqa: E402
DeclarativeWorkflowState,
)
@@ -9,6 +9,15 @@ from agent_framework_declarative._workflows._factory import (
WorkflowFactory,
)
try:
import powerfx # noqa: F401
_powerfx_available = True
except (ImportError, RuntimeError):
_powerfx_available = False
_requires_powerfx = pytest.mark.skipif(not _powerfx_available, reason="PowerFx engine not available")
class TestWorkflowFactoryValidation:
"""Tests for workflow definition validation."""
@@ -58,6 +67,7 @@ actions:
assert workflow.name == "minimal-workflow"
@_requires_powerfx
class TestWorkflowFactoryExecution:
"""Tests for workflow execution."""
@@ -204,6 +214,7 @@ actions:
assert workflow.name == "file-workflow"
@_requires_powerfx
class TestDisplayNameMetadata:
"""Tests for displayName metadata support."""
@@ -231,3 +242,662 @@ actions:
# Should execute successfully with displayName metadata
assert len(outputs) >= 1
class TestWorkflowFactoryToolRegistration:
"""Tests for tool registration."""
def test_register_tool_basic(self):
"""Test registering a tool."""
def my_tool(x: int) -> int:
return x * 2
factory = WorkflowFactory()
result = factory.register_tool("my_tool", my_tool)
# Should return self for fluent chaining
assert result is factory
assert "my_tool" in factory._tools
assert factory._tools["my_tool"](5) == 10
def test_register_multiple_tools(self):
"""Test registering multiple tools with fluent chaining."""
def add(a: int, b: int) -> int:
return a + b
def multiply(a: int, b: int) -> int:
return a * b
factory = WorkflowFactory().register_tool("add", add).register_tool("multiply", multiply)
assert "add" in factory._tools
assert "multiply" in factory._tools
assert factory._tools["add"](2, 3) == 5
assert factory._tools["multiply"](2, 3) == 6
def test_register_tool_non_callable_raises(self):
"""Test that register_tool raises TypeError for non-callable."""
factory = WorkflowFactory()
with pytest.raises(TypeError, match="Expected a callable for tool"):
factory.register_tool("bad_tool", "not_a_function")
def test_register_binding_non_callable_raises(self):
"""Test that register_binding raises TypeError for non-callable."""
factory = WorkflowFactory()
with pytest.raises(TypeError, match="Expected a callable for binding"):
factory.register_binding("bad_binding", 42)
class TestWorkflowFactoryEdgeCases:
"""Tests for edge cases in workflow factory."""
def test_empty_actions_list(self):
"""Test workflow with empty actions list."""
factory = WorkflowFactory()
with pytest.raises(DeclarativeWorkflowError, match="actions"):
factory.create_workflow_from_yaml("""
name: empty-actions
actions: []
""")
def test_unknown_action_kind(self):
"""Test workflow with unknown action kind."""
factory = WorkflowFactory()
with pytest.raises((DeclarativeWorkflowError, ValueError)):
factory.create_workflow_from_yaml("""
name: unknown-action
actions:
- kind: UnknownActionType
value: test
""")
def test_workflow_with_description(self):
"""Test workflow with description field."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: described-workflow
description: This is a test workflow
actions:
- kind: SetValue
path: Local.x
value: 1
""")
assert workflow is not None
assert workflow.name == "described-workflow"
@_requires_powerfx
@pytest.mark.asyncio
async def test_workflow_with_expression_value(self):
"""Test workflow with expression-based value."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: expression-test
actions:
- kind: SetValue
path: Local.x
value: 5
- kind: SetValue
path: Local.y
value: =Local.x
- kind: SendActivity
activity:
text: =Local.y
""")
result = await workflow.run({})
outputs = result.get_outputs()
assert any("5" in str(o) for o in outputs)
@_requires_powerfx
@pytest.mark.asyncio
async def test_workflow_with_nested_if(self):
"""Test workflow with nested If statements."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: nested-if-test
actions:
- kind: SetValue
path: Local.level
value: 2
- kind: If
condition: true
then:
- kind: If
condition: true
then:
- kind: SendActivity
activity:
text: Nested condition passed
""")
result = await workflow.run({})
outputs = result.get_outputs()
assert any("Nested condition passed" in str(o) for o in outputs)
def test_load_from_string_path(self, tmp_path):
"""Test loading a workflow from a string file path."""
workflow_file = tmp_path / "workflow.yaml"
workflow_file.write_text("""
name: string-path-workflow
actions:
- kind: SetValue
path: Local.loaded
value: true
""")
factory = WorkflowFactory()
# Pass as string instead of Path object
workflow = factory.create_workflow_from_yaml_path(str(workflow_file))
assert workflow is not None
assert workflow.name == "string-path-workflow"
@_requires_powerfx
class TestWorkflowFactorySwitch:
"""Tests for Switch/Case action."""
@pytest.mark.asyncio
async def test_switch_with_matching_case(self):
"""Test Switch with a matching case."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: switch-test
actions:
- kind: SetValue
path: Local.color
value: red
- kind: Switch
value: =Local.color
cases:
- match: red
actions:
- kind: SendActivity
activity:
text: Color is red
- match: blue
actions:
- kind: SendActivity
activity:
text: Color is blue
""")
result = await workflow.run({})
outputs = result.get_outputs()
assert any("Color is red" in str(o) for o in outputs)
@pytest.mark.asyncio
async def test_switch_with_default(self):
"""Test Switch falling through to default."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: switch-default-test
actions:
- kind: SetValue
path: Local.color
value: green
- kind: Switch
value: =Local.color
cases:
- match: red
actions:
- kind: SendActivity
activity:
text: Red
- match: blue
actions:
- kind: SendActivity
activity:
text: Blue
default:
- kind: SendActivity
activity:
text: Unknown color
""")
result = await workflow.run({})
outputs = result.get_outputs()
assert any("Unknown color" in str(o) for o in outputs)
@_requires_powerfx
class TestWorkflowFactoryMultipleActionTypes:
"""Tests for workflows with multiple action types."""
@pytest.mark.asyncio
async def test_set_multiple_variables(self):
"""Test SetMultipleVariables action."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: multi-set-test
actions:
- kind: SetMultipleVariables
variables:
- path: Local.a
value: 1
- path: Local.b
value: 2
- path: Local.c
value: 3
- kind: SendActivity
activity:
text: Done
""")
result = await workflow.run({})
outputs = result.get_outputs()
assert any("Done" in str(o) for o in outputs)
@pytest.mark.asyncio
async def test_append_value(self):
"""Test AppendValue action."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: append-test
actions:
- kind: SetValue
path: Local.list
value: []
- kind: AppendValue
path: Local.list
value: first
- kind: AppendValue
path: Local.list
value: second
- kind: SendActivity
activity:
text: Done
""")
result = await workflow.run({})
outputs = result.get_outputs()
assert any("Done" in str(o) for o in outputs)
@pytest.mark.asyncio
async def test_emit_event(self):
"""Test EmitEvent action."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: emit-event-test
actions:
- kind: EmitEvent
event:
name: test_event
data:
message: Hello
- kind: SendActivity
activity:
text: Event emitted
""")
result = await workflow.run({})
outputs = result.get_outputs()
# Workflow should complete
assert any("Event emitted" in str(o) for o in outputs)
class TestWorkflowFactoryYamlErrors:
"""Tests for YAML parsing error handling."""
def test_invalid_yaml_raises(self):
"""Test that invalid YAML raises DeclarativeWorkflowError."""
factory = WorkflowFactory()
with pytest.raises(DeclarativeWorkflowError, match="Invalid YAML"):
factory.create_workflow_from_yaml("""
name: broken-yaml
actions:
- kind: SetValue
path: Local.x
value: [unclosed bracket
""")
def test_non_dict_workflow_raises(self):
"""Test that non-dict workflow definition raises error."""
factory = WorkflowFactory()
with pytest.raises(DeclarativeWorkflowError, match="must be a dictionary"):
factory.create_workflow_from_yaml("- just a list item")
class TestWorkflowFactoryTriggerFormat:
"""Tests for trigger-based workflow format."""
def test_trigger_based_workflow(self):
"""Test workflow with trigger-based format."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
kind: Workflow
trigger:
kind: OnConversationStart
id: my_trigger
actions:
- kind: SetValue
path: Local.x
value: 1
""")
assert workflow is not None
assert workflow.name == "my_trigger"
def test_trigger_workflow_without_id(self):
"""Test trigger workflow without id uses default name."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
kind: Workflow
trigger:
kind: OnConversationStart
actions:
- kind: SetValue
path: Local.x
value: 1
""")
assert workflow is not None
assert workflow.name == "declarative_workflow"
class TestWorkflowFactoryAgentCreation:
"""Tests for agent creation from definitions."""
def test_agent_creation_with_file_reference(self, tmp_path):
"""Test creating agent from file reference."""
from unittest.mock import MagicMock
from agent_framework_declarative import AgentFactory
# Create a minimal agent YAML file (using Prompt kind)
agent_file = tmp_path / "test_agent.yaml"
agent_file.write_text("""
kind: Prompt
name: TestAgent
description: A test agent
instructions: You are a test agent.
""")
# Create a mock client and agent factory
mock_client = MagicMock()
mock_agent = MagicMock()
mock_agent.name = "TestAgent"
mock_client.create_agent.return_value = mock_agent
agent_factory = AgentFactory(client=mock_client)
# Create workflow that references the agent
workflow_file = tmp_path / "workflow.yaml"
workflow_file.write_text(f"""
kind: Workflow
agents:
TestAgent:
file: {agent_file.name}
actions:
- kind: SetValue
path: Local.x
value: 1
""")
factory = WorkflowFactory(agent_factory=agent_factory)
workflow = factory.create_workflow_from_yaml_path(workflow_file)
assert workflow is not None
assert "TestAgent" in workflow._declarative_agents
def test_agent_connection_definition_raises(self):
"""Test that connection-based agent definition raises error."""
factory = WorkflowFactory()
with pytest.raises(DeclarativeWorkflowError, match="Connection-based agents"):
factory.create_workflow_from_yaml("""
kind: Workflow
agents:
MyAgent:
connection: azure-connection
actions:
- kind: SetValue
path: Local.x
value: 1
""")
def test_invalid_agent_definition_raises(self):
"""Test that invalid agent definition raises error."""
factory = WorkflowFactory()
with pytest.raises(DeclarativeWorkflowError, match="Invalid agent definition"):
factory.create_workflow_from_yaml("""
kind: Workflow
agents:
MyAgent:
unknown_field: value
actions:
- kind: SetValue
path: Local.x
value: 1
""")
def test_preregistered_agent_not_overwritten(self):
"""Test that pre-registered agents are not overwritten by definitions."""
class MockAgent:
name = "PreregisteredAgent"
factory = WorkflowFactory(agents={"TestAgent": MockAgent()})
workflow = factory.create_workflow_from_yaml("""
kind: Workflow
agents:
TestAgent:
kind: Agent
name: OverrideAttempt
actions:
- kind: SetValue
path: Local.x
value: 1
""")
assert workflow._declarative_agents["TestAgent"].name == "PreregisteredAgent"
class TestWorkflowFactoryInputSchema:
"""Tests for input schema conversion."""
def test_inputs_to_json_schema_basic(self):
"""Test basic input schema conversion."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: input-schema-test
inputs:
name:
type: string
description: The user's name
age:
type: integer
description: The user's age
actions:
- kind: SetValue
path: Local.x
value: 1
""")
schema = workflow.input_schema
assert schema["type"] == "object"
assert "name" in schema["properties"]
assert "age" in schema["properties"]
assert schema["properties"]["name"]["type"] == "string"
assert schema["properties"]["age"]["type"] == "integer"
assert "name" in schema["required"]
assert "age" in schema["required"]
def test_inputs_schema_with_optional_field(self):
"""Test input schema with optional field."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: optional-input-test
inputs:
required_field:
type: string
required: true
optional_field:
type: string
required: false
actions:
- kind: SetValue
path: Local.x
value: 1
""")
schema = workflow.input_schema
assert "required_field" in schema["required"]
assert "optional_field" not in schema["required"]
def test_inputs_schema_with_default_value(self):
"""Test input schema with default value."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: default-input-test
inputs:
greeting:
type: string
default: Hello
actions:
- kind: SetValue
path: Local.x
value: 1
""")
schema = workflow.input_schema
assert schema["properties"]["greeting"]["default"] == "Hello"
def test_inputs_schema_with_enum(self):
"""Test input schema with enum values."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: enum-input-test
inputs:
color:
type: string
enum:
- red
- green
- blue
actions:
- kind: SetValue
path: Local.x
value: 1
""")
schema = workflow.input_schema
assert schema["properties"]["color"]["enum"] == ["red", "green", "blue"]
def test_inputs_schema_type_mappings(self):
"""Test various type mappings in input schema."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: type-mapping-test
inputs:
str_field:
type: str
int_field:
type: int
float_field:
type: float
bool_field:
type: bool
list_field:
type: list
dict_field:
type: dict
actions:
- kind: SetValue
path: Local.x
value: 1
""")
schema = workflow.input_schema
assert schema["properties"]["str_field"]["type"] == "string"
assert schema["properties"]["int_field"]["type"] == "integer"
assert schema["properties"]["float_field"]["type"] == "number"
assert schema["properties"]["bool_field"]["type"] == "boolean"
assert schema["properties"]["list_field"]["type"] == "array"
assert schema["properties"]["dict_field"]["type"] == "object"
def test_inputs_schema_simple_format(self):
"""Test simple input format (field: type)."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: simple-input-test
inputs:
name: string
count: integer
actions:
- kind: SetValue
path: Local.x
value: 1
""")
schema = workflow.input_schema
assert schema["properties"]["name"]["type"] == "string"
assert schema["properties"]["count"]["type"] == "integer"
assert "name" in schema["required"]
assert "count" in schema["required"]
class TestWorkflowFactoryChaining:
"""Tests for fluent method chaining."""
def test_fluent_agent_registration(self):
"""Test fluent agent registration."""
class MockAgent1:
name = "Agent1"
class MockAgent2:
name = "Agent2"
factory = WorkflowFactory().register_agent("agent1", MockAgent1()).register_agent("agent2", MockAgent2())
assert "agent1" in factory._agents
assert "agent2" in factory._agents
def test_fluent_binding_registration(self):
"""Test fluent binding registration."""
def func1():
return 1
def func2():
return 2
factory = WorkflowFactory().register_binding("func1", func1).register_binding("func2", func2)
assert "func1" in factory._bindings
assert "func2" in factory._bindings
def test_fluent_mixed_registration(self):
"""Test mixed fluent registration."""
class MockAgent:
name = "Agent"
def my_tool():
return "tool"
def my_binding():
return "binding"
factory = (
WorkflowFactory()
.register_agent("agent", MockAgent())
.register_tool("tool", my_tool)
.register_binding("binding", my_binding)
)
assert "agent" in factory._agents
assert "tool" in factory._tools
assert "binding" in factory._bindings
@@ -225,6 +225,335 @@ class TestWorkflowStateResetTurn:
assert state.get("Workflow.Outputs.result") == "done"
class TestWorkflowStateEvalSimple:
"""Tests for _eval_simple fallback PowerFx evaluation."""
def test_negation_prefix(self):
"""Test negation with ! prefix."""
state = WorkflowState()
state.set("Local.value", True)
assert state._eval_simple("!Local.value") is False
state.set("Local.value", False)
assert state._eval_simple("!Local.value") is True
def test_not_function(self):
"""Test Not() function."""
state = WorkflowState()
state.set("Local.flag", True)
assert state._eval_simple("Not(Local.flag)") is False
state.set("Local.flag", False)
assert state._eval_simple("Not(Local.flag)") is True
def test_and_operator(self):
"""Test And operator."""
state = WorkflowState()
state.set("Local.a", True)
state.set("Local.b", True)
assert state._eval_simple("Local.a And Local.b") is True
state.set("Local.b", False)
assert state._eval_simple("Local.a And Local.b") is False
def test_or_operator(self):
"""Test Or operator."""
state = WorkflowState()
state.set("Local.a", False)
state.set("Local.b", False)
assert state._eval_simple("Local.a Or Local.b") is False
state.set("Local.b", True)
assert state._eval_simple("Local.a Or Local.b") is True
def test_or_operator_double_pipe(self):
"""Test || operator."""
state = WorkflowState()
state.set("Local.x", False)
state.set("Local.y", True)
assert state._eval_simple("Local.x || Local.y") is True
def test_less_than(self):
"""Test < comparison."""
state = WorkflowState()
state.set("Local.num", 5)
assert state._eval_simple("Local.num < 10") is True
assert state._eval_simple("Local.num < 3") is False
def test_greater_than(self):
"""Test > comparison."""
state = WorkflowState()
state.set("Local.num", 5)
assert state._eval_simple("Local.num > 3") is True
assert state._eval_simple("Local.num > 10") is False
def test_less_than_or_equal(self):
"""Test <= comparison."""
state = WorkflowState()
state.set("Local.num", 5)
assert state._eval_simple("Local.num <= 5") is True
assert state._eval_simple("Local.num <= 4") is False
def test_greater_than_or_equal(self):
"""Test >= comparison."""
state = WorkflowState()
state.set("Local.num", 5)
assert state._eval_simple("Local.num >= 5") is True
assert state._eval_simple("Local.num >= 6") is False
def test_not_equal(self):
"""Test <> comparison."""
state = WorkflowState()
state.set("Local.val", "hello")
assert state._eval_simple('Local.val <> "world"') is True
assert state._eval_simple('Local.val <> "hello"') is False
def test_equal(self):
"""Test = comparison."""
state = WorkflowState()
state.set("Local.val", "test")
assert state._eval_simple('Local.val = "test"') is True
assert state._eval_simple('Local.val = "other"') is False
def test_addition_numeric(self):
"""Test + operator with numbers."""
state = WorkflowState()
state.set("Local.a", 3)
state.set("Local.b", 4)
assert state._eval_simple("Local.a + Local.b") == 7.0
def test_addition_string_concat(self):
"""Test + operator falls back to string concat."""
state = WorkflowState()
state.set("Local.a", "hello")
state.set("Local.b", "world")
assert state._eval_simple("Local.a + Local.b") == "helloworld"
def test_addition_with_none(self):
"""Test + treats None as 0."""
state = WorkflowState()
state.set("Local.a", 5)
# Local.b doesn't exist, so it's None
assert state._eval_simple("Local.a + Local.b") == 5.0
def test_subtraction(self):
"""Test - operator."""
state = WorkflowState()
state.set("Local.a", 10)
state.set("Local.b", 3)
assert state._eval_simple("Local.a - Local.b") == 7.0
def test_subtraction_with_none(self):
"""Test - treats None as 0."""
state = WorkflowState()
state.set("Local.a", 5)
assert state._eval_simple("Local.a - Local.missing") == 5.0
def test_multiplication(self):
"""Test * operator."""
state = WorkflowState()
state.set("Local.a", 4)
state.set("Local.b", 5)
assert state._eval_simple("Local.a * Local.b") == 20.0
def test_multiplication_with_none(self):
"""Test * treats None as 0."""
state = WorkflowState()
state.set("Local.a", 5)
assert state._eval_simple("Local.a * Local.missing") == 0.0
def test_division(self):
"""Test / operator."""
state = WorkflowState()
state.set("Local.a", 20)
state.set("Local.b", 4)
assert state._eval_simple("Local.a / Local.b") == 5.0
def test_division_by_zero(self):
"""Test / by zero returns None."""
state = WorkflowState()
state.set("Local.a", 10)
state.set("Local.b", 0)
assert state._eval_simple("Local.a / Local.b") is None
def test_string_literal_double_quotes(self):
"""Test string literal with double quotes."""
state = WorkflowState()
assert state._eval_simple('"hello world"') == "hello world"
def test_string_literal_single_quotes(self):
"""Test string literal with single quotes."""
state = WorkflowState()
assert state._eval_simple("'hello world'") == "hello world"
def test_integer_literal(self):
"""Test integer literal."""
state = WorkflowState()
assert state._eval_simple("42") == 42
def test_float_literal(self):
"""Test float literal."""
state = WorkflowState()
assert state._eval_simple("3.14") == 3.14
def test_boolean_true_literal(self):
"""Test true literal (case insensitive)."""
state = WorkflowState()
assert state._eval_simple("true") is True
assert state._eval_simple("True") is True
assert state._eval_simple("TRUE") is True
def test_boolean_false_literal(self):
"""Test false literal (case insensitive)."""
state = WorkflowState()
assert state._eval_simple("false") is False
assert state._eval_simple("False") is False
assert state._eval_simple("FALSE") is False
def test_variable_reference(self):
"""Test simple variable reference."""
state = WorkflowState()
state.set("Local.myvar", "myvalue")
assert state._eval_simple("Local.myvar") == "myvalue"
def test_unknown_expression_returned_as_is(self):
"""Test that unknown expressions are returned as-is."""
state = WorkflowState()
result = state._eval_simple("unknown_identifier")
assert result == "unknown_identifier"
def test_agent_namespace_reference(self):
"""Test Agent namespace variable reference."""
state = WorkflowState()
state.set_agent_result(text="agent response")
assert state._eval_simple("Agent.text") == "agent response"
def test_conversation_namespace_reference(self):
"""Test Conversation namespace variable reference."""
state = WorkflowState()
state.add_conversation_message({"role": "user", "content": "hello"})
result = state._eval_simple("Conversation.messages")
assert len(result) == 1
def test_workflow_inputs_reference(self):
"""Test Workflow.Inputs reference."""
state = WorkflowState(inputs={"name": "test"})
assert state._eval_simple("Workflow.Inputs.name") == "test"
class TestWorkflowStateParseFunctionArgs:
"""Tests for _parse_function_args helper."""
def test_simple_args(self):
"""Test parsing simple comma-separated args."""
state = WorkflowState()
args = state._parse_function_args("1, 2, 3")
assert args == ["1", "2", "3"]
def test_string_args_with_commas(self):
"""Test parsing string args containing commas."""
state = WorkflowState()
args = state._parse_function_args('"hello, world", "another"')
assert args == ['"hello, world"', '"another"']
def test_nested_function_args(self):
"""Test parsing nested function calls."""
state = WorkflowState()
args = state._parse_function_args("Concat(a, b), c")
assert args == ["Concat(a, b)", "c"]
def test_empty_args(self):
"""Test parsing empty args string."""
state = WorkflowState()
args = state._parse_function_args("")
assert args == []
def test_single_arg(self):
"""Test parsing single argument."""
state = WorkflowState()
args = state._parse_function_args("single")
assert args == ["single"]
def test_deeply_nested_parens(self):
"""Test parsing deeply nested parentheses."""
state = WorkflowState()
args = state._parse_function_args("Func1(Func2(a, b)), c")
assert args == ["Func1(Func2(a, b))", "c"]
class TestWorkflowStateEvalIfExpression:
"""Tests for eval_if_expression method."""
def test_dict_values_evaluated(self):
"""Test that dict values are recursively evaluated."""
state = WorkflowState()
state.set("Local.name", "World")
result = state.eval_if_expression({"greeting": "=Local.name", "static": "value"})
assert result == {"greeting": "World", "static": "value"}
def test_list_values_evaluated(self):
"""Test that list values are recursively evaluated."""
state = WorkflowState()
state.set("Local.val", 42)
result = state.eval_if_expression(["=Local.val", "static"])
assert result == [42, "static"]
def test_nested_dict_in_list(self):
"""Test nested dict in list is evaluated."""
state = WorkflowState()
state.set("Local.x", 10)
result = state.eval_if_expression([{"key": "=Local.x"}])
assert result == [{"key": 10}]
class TestWorkflowStateSetErrors:
"""Tests for set() error handling."""
def test_set_workflow_directly_raises(self):
"""Test that setting Workflow directly raises error."""
state = WorkflowState()
with pytest.raises(ValueError, match="Cannot set 'Workflow' directly"):
state.set("Workflow", "value")
def test_set_unknown_workflow_namespace_raises(self):
"""Test that setting unknown Workflow sub-namespace raises."""
state = WorkflowState()
with pytest.raises(ValueError, match="Unknown Workflow namespace"):
state.set("Workflow.Unknown.path", "value")
def test_set_namespace_root_raises(self):
"""Test that setting namespace root raises error."""
state = WorkflowState()
with pytest.raises(ValueError, match="Cannot replace entire namespace"):
state.set("Local", "value")
class TestWorkflowStateGetEdgeCases:
"""Tests for get() edge cases."""
def test_get_empty_path(self):
"""Test get with empty path returns default."""
state = WorkflowState()
assert state.get("", "default") == "default"
def test_get_unknown_namespace(self):
"""Test get from unknown namespace returns default."""
state = WorkflowState()
assert state.get("Unknown.path") is None
assert state.get("Unknown.path", "fallback") == "fallback"
def test_get_with_object_attribute(self):
"""Test get navigates object attributes."""
state = WorkflowState()
class MockObj:
attr = "attribute_value"
state.set("Local.obj", MockObj())
assert state.get("Local.obj.attr") == "attribute_value"
def test_get_unknown_workflow_subspace(self):
"""Test get from unknown Workflow sub-namespace."""
state = WorkflowState()
assert state.get("Workflow.Unknown.path") is None
class TestWorkflowStateConversationIdInit:
"""Tests that WorkflowState generates a real UUID for System.ConversationId."""