diff --git a/.gitignore b/.gitignore index f0f8c09495..bd3f727a0c 100644 --- a/.gitignore +++ b/.gitignore @@ -226,3 +226,4 @@ local.settings.json # Database files *.db +python/dotnet-ref diff --git a/python/dotnet b/python/dotnet new file mode 120000 index 0000000000..b035db739d --- /dev/null +++ b/python/dotnet @@ -0,0 +1 @@ +/Users/evmattso/git/agent-framework-2/dotnet \ No newline at end of file diff --git a/python/packages/core/agent_framework/_workflows/__init__.py b/python/packages/core/agent_framework/_workflows/__init__.py index 42e48c50cf..4fbffb27b2 100644 --- a/python/packages/core/agent_framework/_workflows/__init__.py +++ b/python/packages/core/agent_framework/_workflows/__init__.py @@ -21,6 +21,7 @@ Case, Default, Edge, + EdgeCondition, FanInEdgeGroup, FanOutEdgeGroup, SingleEdgeGroup, @@ -132,6 +133,7 @@ "ConcurrentBuilder", "Default", "Edge", + "EdgeCondition", "EdgeDuplicationError", "Executor", "ExecutorCompletedEvent", diff --git a/python/packages/core/agent_framework/_workflows/_edge.py b/python/packages/core/agent_framework/_workflows/_edge.py index 87a6f7af2b..01812b740f 100644 --- a/python/packages/core/agent_framework/_workflows/_edge.py +++ b/python/packages/core/agent_framework/_workflows/_edge.py @@ -1,17 +1,23 @@ # Copyright (c) Microsoft. All rights reserved. +import inspect import logging import uuid -from collections.abc import Callable, Sequence +from collections.abc import Awaitable, Callable, Sequence from dataclasses import dataclass, field -from typing import Any, ClassVar +from typing import Any, ClassVar, TypeAlias from ._const import INTERNAL_SOURCE_ID from ._executor import Executor from ._model_utils import DictConvertible, encode_value +from ._shared_state import SharedState logger = logging.getLogger(__name__) +# Type alias for edge condition functions. +# Conditions receive (data, shared_state) and return bool (sync or async). +EdgeCondition: TypeAlias = Callable[[Any, SharedState | None], bool | Awaitable[bool]] + def _extract_function_name(func: Callable[..., Any]) -> str: """Map a Python callable to a concise, human-focused identifier. @@ -71,12 +77,24 @@ class Edge(DictConvertible): serialising the edge down to primitives we can reconstruct the topology of a workflow irrespective of the original Python process. + Edge conditions receive `(data, shared_state)` and return a boolean (sync or async). + This unified signature provides consistent access to workflow state for all conditions. + Examples: .. code-block:: python - edge = Edge(source_id="ingest", target_id="score", condition=lambda payload: payload["ready"]) - assert edge.should_route({"ready": True}) is True - assert edge.should_route({"ready": False}) is False + # Simple data-only condition (shared_state can be ignored) + edge = Edge(source_id="ingest", target_id="score", condition=lambda data, state: data["ready"]) + assert await edge.should_route({"ready": True}, None) is True + + + # State-aware condition + async def check_threshold(data, shared_state): + threshold = await shared_state.get("threshold") + return data["score"] > threshold + + + edge = Edge(source_id="score", target_id="output", condition=check_threshold) """ ID_SEPARATOR: ClassVar[str] = "->" @@ -84,13 +102,13 @@ class Edge(DictConvertible): source_id: str target_id: str condition_name: str | None - _condition: Callable[[Any], bool] | None = field(default=None, repr=False, compare=False) + _condition: EdgeCondition | None = field(default=None, repr=False, compare=False) def __init__( self, source_id: str, target_id: str, - condition: Callable[[Any], bool] | None = None, + condition: EdgeCondition | None = None, *, condition_name: str | None = None, ) -> None: @@ -103,9 +121,9 @@ def __init__( target_id: Canonical identifier of the downstream executor instance. condition: - Optional predicate that receives the message payload and returns - `True` when the edge should be traversed. When omitted, the edge is - considered unconditionally active. + Optional predicate that receives `(data, shared_state)` and returns + `True` when the edge should be traversed. Can be sync or async. + When omitted, the edge is unconditionally active. condition_name: Optional override that pins a human-friendly name for the condition when the callable cannot be introspected (for example after @@ -114,7 +132,7 @@ def __init__( Examples: .. code-block:: python - edge = Edge("fetch", "parse", condition=lambda data: data.is_valid) + edge = Edge("fetch", "parse", condition=lambda data, state: data.is_valid) assert edge.source_id == "fetch" assert edge.target_id == "parse" """ @@ -125,7 +143,9 @@ def __init__( self.source_id = source_id self.target_id = target_id self._condition = condition - self.condition_name = _extract_function_name(condition) if condition is not None else condition_name + self.condition_name = ( + _extract_function_name(condition) if condition is not None and condition_name is None else condition_name + ) @property def id(self) -> str: @@ -144,8 +164,16 @@ def id(self) -> str: """ return f"{self.source_id}{self.ID_SEPARATOR}{self.target_id}" - def should_route(self, data: Any) -> bool: - """Evaluate the edge predicate against an incoming payload. + @property + def has_condition(self) -> bool: + """Check if this edge has a condition. + + Returns True if the edge was configured with a condition function. + """ + return self._condition is not None + + async def should_route(self, data: Any, shared_state: SharedState | None) -> bool: + """Evaluate the edge predicate against payload and shared state. When the edge was defined without an explicit predicate the method returns `True`, signalling an unconditional routing rule. Otherwise the @@ -153,16 +181,28 @@ def should_route(self, data: Any) -> bool: this edge. Any exception raised by the callable is deliberately allowed to surface to the caller to avoid masking logic bugs. + The condition receives `(data, shared_state)` and may be sync or async. + + Args: + data: The message payload + shared_state: The workflow's shared state (may be None for simple conditions) + + Returns: + True if the edge should be traversed, False otherwise. + Examples: .. code-block:: python - edge = Edge("stage1", "stage2", condition=lambda payload: payload["score"] > 0.8) - assert edge.should_route({"score": 0.9}) is True - assert edge.should_route({"score": 0.4}) is False + edge = Edge("stage1", "stage2", condition=lambda data, state: data["score"] > 0.8) + assert await edge.should_route({"score": 0.9}, None) is True + assert await edge.should_route({"score": 0.4}, None) is False """ if self._condition is None: return True - return self._condition(data) + result = self._condition(data, shared_state) + if inspect.isawaitable(result): + return bool(await result) + return bool(result) def to_dict(self) -> dict[str, Any]: """Produce a JSON-serialisable view of the edge metadata. @@ -443,12 +483,18 @@ def __init__( self, source_id: str, target_id: str, - condition: Callable[[Any], bool] | None = None, + condition: EdgeCondition | None = None, *, id: str | None = None, ) -> None: """Create a one-to-one edge group between two executors. + Args: + source_id: The source executor ID. + target_id: The target executor ID. + condition: Optional condition function `(data, shared_state) -> bool | Awaitable[bool]`. + id: Optional explicit ID for the edge group. + Examples: .. code-block:: python diff --git a/python/packages/core/agent_framework/_workflows/_edge_runner.py b/python/packages/core/agent_framework/_workflows/_edge_runner.py index 0aa4139c48..f0b636b04b 100644 --- a/python/packages/core/agent_framework/_workflows/_edge_runner.py +++ b/python/packages/core/agent_framework/_workflows/_edge_runner.py @@ -112,7 +112,9 @@ async def send_message(self, message: Message, shared_state: SharedState, ctx: R return False if self._can_handle(self._edge.target_id, message): - if self._edge.should_route(message.data): + route_result = await self._edge.should_route(message.data, shared_state) + + if route_result: span.set_attributes({ OtelAttr.EDGE_GROUP_DELIVERED: True, OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.DELIVERED.value, @@ -162,8 +164,8 @@ def __init__(self, edge_group: FanOutEdgeGroup, executors: dict[str, Executor]) async def send_message(self, message: Message, shared_state: SharedState, ctx: RunnerContext) -> bool: """Send a message through all edges in the fan-out edge group.""" - deliverable_edges = [] - single_target_edge = None + deliverable_edges: list[Edge] = [] + single_target_edge: Edge | None = None # Process routing logic within span with create_edge_group_processing_span( self._edge_group.__class__.__name__, @@ -192,7 +194,9 @@ async def send_message(self, message: Message, shared_state: SharedState, ctx: R if message.target_id in selection_results: edge = self._target_map.get(message.target_id) if edge and self._can_handle(edge.target_id, message): - if edge.should_route(message.data): + route_result = await edge.should_route(message.data, shared_state) + + if route_result: span.set_attributes({ OtelAttr.EDGE_GROUP_DELIVERED: True, OtelAttr.EDGE_GROUP_DELIVERY_STATUS: EdgeGroupDeliveryStatus.DELIVERED.value, @@ -223,8 +227,10 @@ async def send_message(self, message: Message, shared_state: SharedState, ctx: R # If no target ID, send the message to the selected targets for target_id in selection_results: edge = self._target_map[target_id] - if self._can_handle(edge.target_id, message) and edge.should_route(message.data): - deliverable_edges.append(edge) + if self._can_handle(edge.target_id, message): + route_result = await edge.should_route(message.data, shared_state) + if route_result: + deliverable_edges.append(edge) if len(deliverable_edges) > 0: span.set_attributes({ diff --git a/python/packages/core/agent_framework/_workflows/_group_chat.py b/python/packages/core/agent_framework/_workflows/_group_chat.py index 725a5c829c..727711b039 100644 --- a/python/packages/core/agent_framework/_workflows/_group_chat.py +++ b/python/packages/core/agent_framework/_workflows/_group_chat.py @@ -213,7 +213,7 @@ class _GroupChatConfig: # region Default participant factory _GroupChatOrchestratorFactory: TypeAlias = Callable[[_GroupChatConfig], Executor] -_InterceptorSpec: TypeAlias = tuple[Callable[[_GroupChatConfig], Executor], Callable[[Any], bool]] +_InterceptorSpec: TypeAlias = tuple[Callable[[_GroupChatConfig], Executor], Callable[[Any, Any], bool]] def _default_participant_factory( @@ -1701,7 +1701,7 @@ def with_request_handler( self, handler: Callable[[_GroupChatConfig], Executor] | Executor, *, - condition: Callable[[Any], bool], + condition: Callable[[Any, Any], bool], ) -> "GroupChatBuilder": """Register an interceptor factory that creates executors for special requests. diff --git a/python/packages/core/agent_framework/_workflows/_workflow_builder.py b/python/packages/core/agent_framework/_workflows/_workflow_builder.py index 60c959823f..c8676bee58 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_builder.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_builder.py @@ -48,12 +48,12 @@ class _EdgeRegistration: Args: source: The registered source name. target: The registered target name. - condition: An optional condition function for the edge. + condition: An optional condition function `(data, shared_state) -> bool | Awaitable[bool]`. """ source: str target: str - condition: Callable[[Any], bool] | None = None + condition: Callable[[Any, Any], bool | Any] | None = None @dataclass @@ -446,7 +446,7 @@ def add_edge( self, source: Executor | AgentProtocol | str, target: Executor | AgentProtocol | str, - condition: Callable[[Any], bool] | None = None, + condition: Callable[[Any, Any], bool | Any] | None = None, ) -> Self: """Add a directed edge between two executors. @@ -456,13 +456,15 @@ def add_edge( Args: source: The source executor or registered name of the source factory for the edge. target: The target executor or registered name of the target factory for the edge. - condition: An optional condition function that determines whether the edge - should be traversed based on the message. + condition: An optional condition function `(data, shared_state) -> bool | Awaitable[bool]` + that determines whether the edge should be traversed. + For simple conditions that don't need shared state, you can ignore the + second parameter: `lambda data, _: data["ready"]`. - Note: If instances are provided for both source and target, they will be shared across - all workflow instances created from the built Workflow. To avoid this, consider - registering the executors and agents using `register_executor` and `register_agent` - and referencing them by factory name for lazy initialization instead. + Note: If instances are provided for both source and target, they will be shared across + all workflow instances created from the built Workflow. To avoid this, consider + registering the executors and agents using `register_executor` and `register_agent` + and referencing them by factory name for lazy initialization instead. Returns: Self: The WorkflowBuilder instance for method chaining. @@ -498,7 +500,7 @@ async def process(self, count: int, ctx: WorkflowContext[Never, str]) -> None: # With a condition - def only_large_numbers(msg: int) -> bool: + def only_large_numbers(msg: int, shared_state) -> bool: return msg > 100 @@ -529,7 +531,7 @@ def only_large_numbers(msg: int) -> bool: target_exec = self._maybe_wrap_agent(target) # type: ignore[arg-type] source_id = self._add_executor(source_exec) target_id = self._add_executor(target_exec) - self._edge_groups.append(SingleEdgeGroup(source_id, target_id, condition)) # type: ignore[call-arg] + self._edge_groups.append(SingleEdgeGroup(source_id, target_id, condition)) return self def add_fan_out_edges( diff --git a/python/packages/core/agent_framework/openai/_chat_client.py b/python/packages/core/agent_framework/openai/_chat_client.py index 73605fadef..62c9a4c958 100644 --- a/python/packages/core/agent_framework/openai/_chat_client.py +++ b/python/packages/core/agent_framework/openai/_chat_client.py @@ -378,8 +378,10 @@ def _openai_chat_message_parser(self, message: ChatMessage) -> list[dict[str, An args["tool_calls"] = [self._openai_content_parser(content)] # type: ignore case FunctionResultContent(): args["tool_call_id"] = content.call_id - if content.result is not None: - args["content"] = prepare_function_call_results(content.result) + # Always include content for tool results, even if None (API requires it) + args["content"] = ( + prepare_function_call_results(content.result) if content.result is not None else "" + ) case _: if "content" not in args: args["content"] = [] diff --git a/python/packages/core/tests/workflow/test_edge.py b/python/packages/core/tests/workflow/test_edge.py index 316cae7a39..dbdb40f358 100644 --- a/python/packages/core/tests/workflow/test_edge.py +++ b/python/packages/core/tests/workflow/test_edge.py @@ -137,9 +137,17 @@ def test_edge_can_handle(): source = MockExecutor(id="source_executor") target = MockExecutor(id="target_executor") + _ = Edge(source_id=source.id, target_id=target.id) + + +async def test_edge_should_route(): + """Test edge should_route with no condition.""" + source = MockExecutor(id="source_executor") + target = MockExecutor(id="target_executor") + edge = Edge(source_id=source.id, target_id=target.id) - assert edge.should_route(MockMessage(data="test")) + assert await edge.should_route(MockMessage(data="test"), None) # endregion Edge @@ -165,7 +173,7 @@ def test_single_edge_group_with_condition(): source = MockExecutor(id="source_executor") target = MockExecutor(id="target_executor") - edge_group = SingleEdgeGroup(source_id=source.id, target_id=target.id, condition=lambda x: x.data == "test") + edge_group = SingleEdgeGroup(source_id=source.id, target_id=target.id, condition=lambda x, _: x.data == "test") assert edge_group.source_executor_ids == [source.id] assert edge_group.target_executor_ids == [target.id] @@ -257,7 +265,7 @@ async def test_single_edge_group_send_message_with_condition_pass() -> None: executors: dict[str, Executor] = {source.id: source, target.id: target} # Create edge group with condition that passes when data == "test" - edge_group = SingleEdgeGroup(source_id=source.id, target_id=target.id, condition=lambda x: x.data == "test") + edge_group = SingleEdgeGroup(source_id=source.id, target_id=target.id, condition=lambda x, _: x.data == "test") edge_runner = create_edge_runner(edge_group, executors) shared_state = SharedState() @@ -279,7 +287,7 @@ async def test_single_edge_group_send_message_with_condition_fail() -> None: executors: dict[str, Executor] = {source.id: source, target.id: target} # Create edge group with condition that passes when data == "test" - edge_group = SingleEdgeGroup(source_id=source.id, target_id=target.id, condition=lambda x: x.data == "test") + edge_group = SingleEdgeGroup(source_id=source.id, target_id=target.id, condition=lambda x, _: x.data == "test") edge_runner = create_edge_runner(edge_group, executors) shared_state = SharedState() @@ -350,7 +358,7 @@ async def test_single_edge_group_tracing_condition_failure(span_exporter) -> Non target = MockExecutor(id="target_executor") executors: dict[str, Executor] = {source.id: source, target.id: target} - edge_group = SingleEdgeGroup(source_id=source.id, target_id=target.id, condition=lambda x: x.data == "pass") + edge_group = SingleEdgeGroup(source_id=source.id, target_id=target.id, condition=lambda x, _: x.data == "pass") edge_runner = create_edge_runner(edge_group, executors) shared_state = SharedState() diff --git a/python/packages/declarative/agent_framework_declarative/__init__.py b/python/packages/declarative/agent_framework_declarative/__init__.py index bfc1bdffdc..1f882fc6f7 100644 --- a/python/packages/declarative/agent_framework_declarative/__init__.py +++ b/python/packages/declarative/agent_framework_declarative/__init__.py @@ -3,10 +3,30 @@ from importlib import metadata from ._loader import AgentFactory, DeclarativeLoaderError, ProviderLookupError, ProviderTypeMapping +from ._workflows import ( + AgentInvocationError, + DeclarativeWorkflowError, + ExternalInputRequest, + ExternalInputResponse, + WorkflowFactory, + WorkflowState, +) try: __version__ = metadata.version(__name__) except metadata.PackageNotFoundError: __version__ = "0.0.0" # Fallback for development mode -__all__ = ["AgentFactory", "DeclarativeLoaderError", "ProviderLookupError", "ProviderTypeMapping", "__version__"] +__all__ = [ + "AgentFactory", + "AgentInvocationError", + "DeclarativeLoaderError", + "DeclarativeWorkflowError", + "ExternalInputRequest", + "ExternalInputResponse", + "ProviderLookupError", + "ProviderTypeMapping", + "WorkflowFactory", + "WorkflowState", + "__version__", +] diff --git a/python/packages/declarative/agent_framework_declarative/_loader.py b/python/packages/declarative/agent_framework_declarative/_loader.py index b5ae1683ba..3472dfaf19 100644 --- a/python/packages/declarative/agent_framework_declarative/_loader.py +++ b/python/packages/declarative/agent_framework_declarative/_loader.py @@ -109,6 +109,53 @@ class ProviderLookupError(DeclarativeLoaderError): class AgentFactory: + """Factory for creating ChatAgent instances from declarative YAML definitions. + + AgentFactory parses YAML agent definitions (PromptAgent kind) and creates + configured ChatAgent instances with the appropriate chat client, tools, + and response format. + + Examples: + .. code-block:: python + + from agent_framework_declarative import AgentFactory + + # Create agent from YAML file + factory = AgentFactory() + agent = factory.create_agent_from_yaml_path("agent.yaml") + + # Run the agent + async for event in agent.run_stream("Hello!"): + print(event) + + .. code-block:: python + + from agent_framework.azure import AzureOpenAIChatClient + from agent_framework_declarative import AgentFactory + + # With pre-configured chat client + client = AzureOpenAIChatClient() + factory = AgentFactory(chat_client=client) + agent = factory.create_agent_from_yaml_path("agent.yaml") + + .. code-block:: python + + from agent_framework_declarative import AgentFactory + + # From inline YAML string + yaml_content = ''' + kind: Prompt + name: GreetingAgent + instructions: You are a friendly assistant. + model: + id: gpt-4o + provider: AzureOpenAI + ''' + + factory = AgentFactory() + agent = factory.create_agent_from_yaml(yaml_content) + """ + def __init__( self, *, @@ -120,38 +167,55 @@ def __init__( default_provider: str = "AzureAIClient", env_file: str | None = None, ) -> None: - """Create the agent factory, with bindings. + """Create the agent factory. Args: - chat_client: An optional ChatClientProtocol instance to use as a dependency, - this will be passed to the ChatAgent that get's created. + chat_client: An optional ChatClientProtocol instance to use as a dependency. + This will be passed to the ChatAgent that gets created. If you need to create multiple agents with different chat clients, do not pass this and instead provide the chat client in the YAML definition. bindings: An optional dictionary of bindings to use when creating agents. connections: An optional dictionary of connections to resolve ReferenceConnections. client_kwargs: An optional dictionary of keyword arguments to pass to chat client constructor. additional_mappings: An optional dictionary to extend the provider type to object mapping. - Should have the structure: + default_provider: The default provider used when model.provider is not specified. + Defaults to "AzureAIClient". + env_file: An optional path to a .env file to load environment variables from. + + Examples: + .. code-block:: python - ..code-block:: python + from agent_framework_declarative import AgentFactory - additional_mappings = { - "Provider.ApiType": { - "package": "package.name", - "name": "ClassName", - "model_id_field": "field_name_in_constructor", + # Minimal initialization + factory = AgentFactory() + + .. code-block:: python + + from agent_framework.azure import AzureOpenAIChatClient + from agent_framework_declarative import AgentFactory + + # With shared chat client + client = AzureOpenAIChatClient() + factory = AgentFactory( + chat_client=client, + env_file=".env", + ) + + .. code-block:: python + + from agent_framework_declarative import AgentFactory + + # With custom provider mappings + factory = AgentFactory( + additional_mappings={ + "CustomProvider.Chat": { + "package": "my_package.clients", + "name": "CustomChatClient", + "model_id_field": "model_name", }, - ... - } - - Here, "Provider.ApiType" is the lookup key used when both provider and apiType are specified in the - model, "Provider" is also allowed. - Package refers to which model needs to be imported, Name is the class name of the ChatClientProtocol - implementation, and model_id_field is the name of the field in the constructor - that accepts the model.id value. - default_provider: The default provider used when model.provider is not specified, - default is "AzureAIClient". - env_file: An optional path to a .env file to load environment variables from. + }, + ) """ self.chat_client = chat_client self.bindings = bindings @@ -165,14 +229,15 @@ def create_agent_from_yaml_path(self, yaml_path: str | Path) -> ChatAgent: """Create a ChatAgent from a YAML file path. This method does the following things: - 1. Loads the YAML file into a AgentSchema object using open and agent_schema_dispatch. + + 1. Loads the YAML file into an AgentSchema object. 2. Validates that the loaded object is a PromptAgent. 3. Creates the appropriate ChatClient based on the model provider and apiType. 4. Parses the tools, options, and response format from the PromptAgent. 5. Creates and returns a ChatAgent instance with the configured properties. Args: - yaml_path: Path to the YAML file representation of a AgentSchema object + yaml_path: Path to the YAML file representation of a PromptAgent. Returns: The ``ChatAgent`` instance created from the YAML file. @@ -183,6 +248,28 @@ def create_agent_from_yaml_path(self, yaml_path: str | Path) -> ChatAgent: ValueError: If a ReferenceConnection cannot be resolved. ModuleNotFoundError: If the required module for the provider type cannot be imported. AttributeError: If the required class for the provider type cannot be found in the module. + + Examples: + .. code-block:: python + + from agent_framework_declarative import AgentFactory + + factory = AgentFactory() + agent = factory.create_agent_from_yaml_path("agents/support_agent.yaml") + + # Execute the agent + async for event in agent.run_stream("Help me with my order"): + print(event) + + .. code-block:: python + + from pathlib import Path + from agent_framework_declarative import AgentFactory + + # Using Path object for cross-platform compatibility + agent_path = Path(__file__).parent / "agents" / "writer.yaml" + factory = AgentFactory() + agent = factory.create_agent_from_yaml_path(agent_path) """ if not isinstance(yaml_path, Path): yaml_path = Path(yaml_path) @@ -196,14 +283,15 @@ def create_agent_from_yaml(self, yaml_str: str) -> ChatAgent: """Create a ChatAgent from a YAML string. This method does the following things: - 1. Loads the YAML string into a AgentSchema object using agent_schema_dispatch. + + 1. Loads the YAML string into an AgentSchema object. 2. Validates that the loaded object is a PromptAgent. 3. Creates the appropriate ChatClient based on the model provider and apiType. 4. Parses the tools, options, and response format from the PromptAgent. 5. Creates and returns a ChatAgent instance with the configured properties. Args: - yaml_str: YAML string representation of a AgentSchema object + yaml_str: YAML string representation of a PromptAgent. Returns: The ``ChatAgent`` instance created from the YAML string. @@ -214,10 +302,102 @@ def create_agent_from_yaml(self, yaml_str: str) -> ChatAgent: ValueError: If a ReferenceConnection cannot be resolved. ModuleNotFoundError: If the required module for the provider type cannot be imported. AttributeError: If the required class for the provider type cannot be found in the module. + + Examples: + .. code-block:: python + + from agent_framework_declarative import AgentFactory + + yaml_content = ''' + kind: Prompt + name: TranslationAgent + description: Translates text between languages + instructions: | + You are a translation assistant. + Translate user input to the requested language. + model: + id: gpt-4o + provider: AzureOpenAI + options: + temperature: 0.3 + ''' + + factory = AgentFactory() + agent = factory.create_agent_from_yaml(yaml_content) + + .. code-block:: python + + from agent_framework_declarative import AgentFactory + from pydantic import BaseModel + + # Agent with structured output + yaml_content = ''' + kind: Prompt + name: SentimentAnalyzer + instructions: Analyze the sentiment of the input text. + model: + id: gpt-4o + outputSchema: + type: object + properties: + sentiment: + type: string + enum: [positive, negative, neutral] + confidence: + type: number + ''' + + factory = AgentFactory() + agent = factory.create_agent_from_yaml(yaml_content) + """ + return self.create_agent_from_dict(yaml.safe_load(yaml_str)) + + def create_agent_from_dict(self, agent_def: dict[str, Any]) -> ChatAgent: + """Create a ChatAgent from a dictionary definition. + + This method does the following things: + + 1. Converts the dictionary into an AgentSchema object. + 2. Validates that the loaded object is a PromptAgent. + 3. Creates the appropriate ChatClient based on the model provider and apiType. + 4. Parses the tools, options, and response format from the PromptAgent. + 5. Creates and returns a ChatAgent instance with the configured properties. + + Args: + agent_def: Dictionary representation of a PromptAgent. + + Returns: + The `ChatAgent` instance created from the dictionary. + + Raises: + DeclarativeLoaderError: If the dictionary does not represent a PromptAgent. + ProviderLookupError: If the provider type is unknown or unsupported. + ValueError: If a ReferenceConnection cannot be resolved. + ModuleNotFoundError: If the required module for the provider type cannot be imported. + AttributeError: If the required class for the provider type cannot be found in the module. + + Examples: + .. code-block:: python + + from agent_framework_declarative import AgentFactory + + agent_def = { + "kind": "Prompt", + "name": "TranslationAgent", + "description": "Translates text between languages", + "instructions": "You are a translation assistant.", + "model": { + "id": "gpt-4o", + "provider": "AzureOpenAI", + }, + } + + factory = AgentFactory() + agent = factory.create_agent_from_dict(agent_def) """ - prompt_agent = agent_schema_dispatch(yaml.safe_load(yaml_str)) + prompt_agent = agent_schema_dispatch(agent_def) if not isinstance(prompt_agent, PromptAgent): - raise DeclarativeLoaderError("Only yaml definitions for a PromptAgent are supported for agent creation.") + raise DeclarativeLoaderError("Only definitions for a PromptAgent are supported for agent creation.") # Step 1: Create the ChatClient client = self._get_client(prompt_agent) diff --git a/python/packages/declarative/agent_framework_declarative/_models.py b/python/packages/declarative/agent_framework_declarative/_models.py index aaba468bdf..1f54fdfd0d 100644 --- a/python/packages/declarative/agent_framework_declarative/_models.py +++ b/python/packages/declarative/agent_framework_declarative/_models.py @@ -10,8 +10,10 @@ try: from powerfx import Engine - engine = Engine() -except ImportError: + engine: Engine | None = Engine() +except (ImportError, RuntimeError): + # ImportError: powerfx package not installed + # RuntimeError: .NET runtime not available or misconfigured engine = None if sys.version_info >= (3, 11): @@ -49,12 +51,13 @@ def _try_powerfx_eval(value: str | None, log_value: bool = True) -> str | None: ) return value try: - return engine.eval(value[1:], symbols={"Env": dict(os.environ)}) + result: str | None = engine.eval(value[1:], symbols={"Env": dict(os.environ)}) + return result except Exception as exc: if log_value: - logger.debug("PowerFx evaluation failed for value '%s': %s", value, exc) + logger.debug(f"PowerFx evaluation failed for value '{value}': {exc}") else: - logger.debug("PowerFx evaluation failed for value (first five characters shown) '%s': %s", value[:5], exc) + logger.debug(f"PowerFx evaluation failed for value (first five characters shown) '{value[:5]}': {exc}") return value @@ -99,7 +102,7 @@ def from_dict( # Only dispatch if we're being called on the base Property class if cls is not Property: # We're being called on a subclass, use the normal from_dict - return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[misc] + 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) @@ -109,7 +112,7 @@ def from_dict( if kind == "object": return ObjectProperty.from_dict(value, dependencies=dependencies) # Default to Property for kind="property" or empty - return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[misc] + return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[attr-defined, no-any-return] class ArrayProperty(Property): @@ -152,7 +155,7 @@ def __init__( default: Any | None = None, example: Any | None = None, enum: list[Any] | None = None, - properties: list[Property] | dict[str, Property] | None = None, + properties: list[Property] | dict[str, dict[str, Any]] | None = None, ) -> None: super().__init__( name=name, @@ -184,7 +187,7 @@ def __init__( self, examples: list[dict[str, Any]] | None = None, strict: bool = False, - properties: list[Property] | dict[str, Property] | None = None, + properties: list[Property] | dict[str, dict[str, Any]] | None = None, ) -> None: self.examples = examples or [] self.strict = strict @@ -209,7 +212,7 @@ def from_dict( # Filter out 'kind', 'type', 'name', and 'description' fields that may appear in YAML # but aren't PropertySchema params kwargs = {k: v for k, v in value.items() if k not in ("type", "kind", "name", "description")} - return SerializationMixin.from_dict.__func__(cls, kwargs, dependencies=dependencies) # type: ignore[misc] + return SerializationMixin.from_dict.__func__(cls, kwargs, dependencies=dependencies) # type: ignore[attr-defined, no-any-return] def to_json_schema(self) -> dict[str, Any]: """Get a schema out of this PropertySchema to create pydantic models.""" @@ -251,26 +254,26 @@ def from_dict( # Only dispatch if we're being called on the base Connection class if cls is not Connection: # We're being called on a subclass, use the normal from_dict - return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[misc] + return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[attr-defined, no-any-return] kind = value.get("kind", "").lower() if kind == "reference": - return SerializationMixin.from_dict.__func__( # type: ignore[misc] + return SerializationMixin.from_dict.__func__( # type: ignore[attr-defined, no-any-return] ReferenceConnection, value, dependencies=dependencies ) if kind == "remote": - return SerializationMixin.from_dict.__func__( # type: ignore[misc] + return SerializationMixin.from_dict.__func__( # type: ignore[attr-defined, no-any-return] RemoteConnection, value, dependencies=dependencies ) if kind in ("key", "apikey"): - return SerializationMixin.from_dict.__func__( # type: ignore[misc] + return SerializationMixin.from_dict.__func__( # type: ignore[attr-defined, no-any-return] ApiKeyConnection, value, dependencies=dependencies ) if kind == "anonymous": - return SerializationMixin.from_dict.__func__( # type: ignore[misc] + return SerializationMixin.from_dict.__func__( # type: ignore[attr-defined, no-any-return] AnonymousConnection, value, dependencies=dependencies ) - return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[misc] + return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[attr-defined, no-any-return] class ReferenceConnection(Connection): @@ -489,13 +492,13 @@ def from_dict( # Only dispatch if we're being called on the base AgentDefinition class if cls is not AgentDefinition: # We're being called on a subclass, use the normal from_dict - return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[misc] + return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[attr-defined, no-any-return] kind = value.get("kind", "") if kind == "Prompt" or kind == "Agent": return PromptAgent.from_dict(value, dependencies=dependencies) # Default to AgentDefinition - return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[misc] + return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[attr-defined, no-any-return] TTool = TypeVar("TTool", bound="Tool") @@ -535,39 +538,39 @@ def from_dict( # Only dispatch if we're being called on the base Tool class if cls is not Tool: # We're being called on a subclass, use the normal from_dict - return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[misc] + return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[attr-defined, no-any-return] kind = value.get("kind", "") if kind == "function": - return SerializationMixin.from_dict.__func__( # type: ignore[misc] + return SerializationMixin.from_dict.__func__( # type: ignore[attr-defined, no-any-return] FunctionTool, value, dependencies=dependencies ) if kind == "custom": - return SerializationMixin.from_dict.__func__( # type: ignore[misc] + return SerializationMixin.from_dict.__func__( # type: ignore[attr-defined, no-any-return] CustomTool, value, dependencies=dependencies ) if kind == "web_search": - return SerializationMixin.from_dict.__func__( # type: ignore[misc] + return SerializationMixin.from_dict.__func__( # type: ignore[attr-defined, no-any-return] WebSearchTool, value, dependencies=dependencies ) if kind == "file_search": - return SerializationMixin.from_dict.__func__( # type: ignore[misc] + return SerializationMixin.from_dict.__func__( # type: ignore[attr-defined, no-any-return] FileSearchTool, value, dependencies=dependencies ) if kind == "mcp": - return SerializationMixin.from_dict.__func__( # type: ignore[misc] + return SerializationMixin.from_dict.__func__( # type: ignore[attr-defined, no-any-return] McpTool, value, dependencies=dependencies ) if kind == "openapi": - return SerializationMixin.from_dict.__func__( # type: ignore[misc] + return SerializationMixin.from_dict.__func__( # type: ignore[attr-defined, no-any-return] OpenApiTool, value, dependencies=dependencies ) if kind == "code_interpreter": - return SerializationMixin.from_dict.__func__( # type: ignore[misc] + return SerializationMixin.from_dict.__func__( # type: ignore[attr-defined, no-any-return] CodeInterpreterTool, value, dependencies=dependencies ) # Default to base Tool class - return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[misc] + return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[attr-defined, no-any-return] class FunctionTool(Tool): @@ -865,18 +868,18 @@ def from_dict( # Only dispatch if we're being called on the base Resource class if cls is not Resource: # We're being called on a subclass, use the normal from_dict - return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[misc] + return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[attr-defined, no-any-return] kind = value.get("kind", "") if kind == "model": - return SerializationMixin.from_dict.__func__( # type: ignore[misc] + return SerializationMixin.from_dict.__func__( # type: ignore[attr-defined, no-any-return] ModelResource, value, dependencies=dependencies ) if kind == "tool": - return SerializationMixin.from_dict.__func__( # type: ignore[misc] + return SerializationMixin.from_dict.__func__( # type: ignore[attr-defined, no-any-return] ToolResource, value, dependencies=dependencies ) - return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[misc] + return SerializationMixin.from_dict.__func__(cls, value, dependencies=dependencies) # type: ignore[attr-defined, no-any-return] class ModelResource(Resource): diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/__init__.py b/python/packages/declarative/agent_framework_declarative/_workflows/__init__.py new file mode 100644 index 0000000000..11cdabb87b --- /dev/null +++ b/python/packages/declarative/agent_framework_declarative/_workflows/__init__.py @@ -0,0 +1,139 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Declarative workflow support for agent-framework. + +This module provides the ability to create executable Workflow objects from YAML definitions, +enabling multi-agent orchestration patterns like Foreach, conditionals, and agent invocations. + +Graph-based execution enables: +- Checkpointing at action boundaries +- Workflow visualization +- Pause/resume capabilities +- Full integration with the workflow runtime +""" + +from ._declarative_base import ( + DECLARATIVE_STATE_KEY, + ActionComplete, + ActionTrigger, + ConversationData, + DeclarativeActionExecutor, + DeclarativeMessage, + DeclarativeStateData, + DeclarativeWorkflowState, + LoopControl, + LoopIterationResult, +) +from ._declarative_builder import ALL_ACTION_EXECUTORS, DeclarativeWorkflowBuilder +from ._executors_agents import ( + AGENT_ACTION_EXECUTORS, + AGENT_REGISTRY_KEY, + TOOL_REGISTRY_KEY, + AgentInvocationError, + AgentResult, + ExternalInputRequest, + ExternalInputResponse, + ExternalLoopState, + InvokeAzureAgentExecutor, + InvokeToolExecutor, +) +from ._executors_basic import ( + BASIC_ACTION_EXECUTORS, + AppendValueExecutor, + ClearAllVariablesExecutor, + EmitEventExecutor, + ResetVariableExecutor, + SendActivityExecutor, + SetMultipleVariablesExecutor, + SetTextVariableExecutor, + SetValueExecutor, + SetVariableExecutor, +) +from ._executors_control_flow import ( + CONTROL_FLOW_EXECUTORS, + BreakLoopExecutor, + ContinueLoopExecutor, + EndConversationExecutor, + EndWorkflowExecutor, + ForeachInitExecutor, + ForeachNextExecutor, + JoinExecutor, +) +from ._executors_external_input import ( + EXTERNAL_INPUT_EXECUTORS, + ConfirmationExecutor, + HumanInputRequest, + QuestionChoice, + QuestionExecutor, + RequestExternalInputExecutor, + WaitForInputExecutor, +) +from ._factory import DeclarativeWorkflowError, WorkflowFactory +from ._handlers import ActionHandler, action_handler, get_action_handler +from ._human_input import ( + ExternalLoopEvent, + QuestionRequest, + process_external_loop, + validate_input_response, +) +from ._state import WorkflowState + +__all__ = [ + "AGENT_ACTION_EXECUTORS", + "AGENT_REGISTRY_KEY", + "ALL_ACTION_EXECUTORS", + "BASIC_ACTION_EXECUTORS", + "CONTROL_FLOW_EXECUTORS", + "DECLARATIVE_STATE_KEY", + "EXTERNAL_INPUT_EXECUTORS", + "TOOL_REGISTRY_KEY", + "ActionComplete", + "ActionHandler", + "ActionTrigger", + "AgentInvocationError", + "AgentResult", + "AppendValueExecutor", + "BreakLoopExecutor", + "ClearAllVariablesExecutor", + "ConfirmationExecutor", + "ContinueLoopExecutor", + "ConversationData", + "DeclarativeActionExecutor", + "DeclarativeMessage", + "DeclarativeStateData", + "DeclarativeWorkflowBuilder", + "DeclarativeWorkflowError", + "DeclarativeWorkflowState", + "EmitEventExecutor", + "EndConversationExecutor", + "EndWorkflowExecutor", + "ExternalInputRequest", + "ExternalInputResponse", + "ExternalLoopEvent", + "ExternalLoopState", + "ForeachInitExecutor", + "ForeachNextExecutor", + "HumanInputRequest", + "InvokeAzureAgentExecutor", + "InvokeToolExecutor", + "JoinExecutor", + "LoopControl", + "LoopIterationResult", + "QuestionChoice", + "QuestionExecutor", + "QuestionRequest", + "RequestExternalInputExecutor", + "ResetVariableExecutor", + "SendActivityExecutor", + "SetMultipleVariablesExecutor", + "SetTextVariableExecutor", + "SetValueExecutor", + "SetVariableExecutor", + "WaitForInputExecutor", + "WorkflowFactory", + "WorkflowState", + "action_handler", + "get_action_handler", + "process_external_loop", + "validate_input_response", +] diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_actions_agents.py b/python/packages/declarative/agent_framework_declarative/_workflows/_actions_agents.py new file mode 100644 index 0000000000..2ee9dc411c --- /dev/null +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_actions_agents.py @@ -0,0 +1,497 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Agent invocation action handlers for declarative workflows. + +This module implements handlers for: +- InvokeAzureAgent: Invoke a hosted Azure AI agent +- InvokePromptAgent: Invoke a local prompt-based agent +""" + +import json +from collections.abc import AsyncGenerator +from typing import Any, cast + +from agent_framework import get_logger +from agent_framework._types import ChatMessage + +from ._handlers import ( + ActionContext, + AgentResponseEvent, + AgentStreamingChunkEvent, + WorkflowEvent, + action_handler, +) +from ._human_input import ExternalLoopEvent, QuestionRequest + +logger = get_logger("agent_framework.declarative.workflows.actions") + + +def _build_messages_from_state(ctx: ActionContext) -> list[ChatMessage]: + """Build the message list to send to an agent. + + This collects messages from: + 1. Conversation history + 2. Current input (if first agent call) + 3. Additional context from instructions + + Args: + ctx: The action context + + Returns: + List of ChatMessage objects to send to the agent + """ + messages: list[ChatMessage] = [] + + # Get conversation history + history = ctx.state.get("conversation.messages", []) + if history: + messages.extend(history) + + return messages + + +def _extract_text_from_response(response: Any) -> str | None: + """Extract text content from an agent response. + + Args: + response: The agent response object + + Returns: + The text content, or None if not available + """ + # Handle various response types + if hasattr(response, "text"): + text_val = response.text + return str(text_val) if text_val is not None else None + if hasattr(response, "content"): + content = response.content + if isinstance(content, str): + return content + if hasattr(content, "text"): + content_text = content.text + return str(content_text) if content_text is not None else None + if isinstance(response, str): + return response + return None + + +@action_handler("InvokeAzureAgent") +async def handle_invoke_azure_agent(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: + """Invoke a hosted Azure AI agent. + + Supports both Python-style and .NET-style YAML schemas: + + Python-style schema: + kind: InvokeAzureAgent + agent: agentName + input: =expression or literal input + outputPath: turn.response + + .NET-style schema: + kind: InvokeAzureAgent + agent: + name: AgentName + conversationId: =System.ConversationId + input: + arguments: + param1: value1 + messages: =expression + output: + messages: Local.Response + responseObject: Local.StructuredResponse + """ + # Get agent name - support both formats + agent_config: dict[str, Any] | str | None = ctx.action.get("agent") + agent_name: str | None = None + if isinstance(agent_config, dict): + agent_name = str(agent_config.get("name")) if agent_config.get("name") else None + # Support dynamic agent name from expression + if agent_name and isinstance(agent_name, str) and agent_name.startswith("="): + evaluated = ctx.state.eval_if_expression(agent_name) + agent_name = str(evaluated) if evaluated is not None else None + elif isinstance(agent_config, str): + agent_name = agent_config + + if not agent_name: + logger.warning("InvokeAzureAgent action missing 'agent' or 'agent.name' property") + return + + # Get input configuration + input_config: dict[str, Any] | Any = ctx.action.get("input", {}) + input_arguments: dict[str, Any] = {} + input_messages: Any = None + external_loop_when: str | None = None + if isinstance(input_config, dict): + input_config_typed = cast(dict[str, Any], input_config) + input_arguments = cast(dict[str, Any], input_config_typed.get("arguments") or {}) + input_messages = input_config_typed.get("messages") + # Extract external loop configuration + external_loop = input_config_typed.get("externalLoop") + if isinstance(external_loop, dict): + external_loop_typed = cast(dict[str, Any], external_loop) + external_loop_when = str(external_loop_typed.get("when")) if external_loop_typed.get("when") else None + else: + input_messages = input_config # Treat as message directly + + # Get output configuration (.NET style) + output_config: dict[str, Any] | Any = ctx.action.get("output", {}) + output_messages_var: str | None = None + output_response_obj_var: str | None = None + if isinstance(output_config, dict): + output_config_typed = cast(dict[str, Any], output_config) + output_messages_var = str(output_config_typed.get("messages")) if output_config_typed.get("messages") else None + output_response_obj_var = ( + str(output_config_typed.get("responseObject")) if output_config_typed.get("responseObject") else None + ) + # auto_send is defined but not used currently + _auto_send: bool = bool(output_config_typed.get("autoSend", True)) + + # Legacy Python style output path + output_path = ctx.action.get("outputPath") + + # Other properties + conversation_id = ctx.action.get("conversationId") + instructions = ctx.action.get("instructions") + tools_config: list[dict[str, Any]] = ctx.action.get("tools", []) + + # Get the agent from registry + agent = ctx.agents.get(agent_name) + if agent is None: + logger.error(f"InvokeAzureAgent: agent '{agent_name}' not found in registry") + return + + # Evaluate conversation ID + if conversation_id: + evaluated_conv_id = ctx.state.eval_if_expression(conversation_id) + ctx.state.set("system.ConversationId", evaluated_conv_id) + + # Evaluate instructions (unused currently but may be used for prompting) + _ = ctx.state.eval_if_expression(instructions) if instructions else None + + # Build messages + messages = _build_messages_from_state(ctx) + + # Handle input messages from .NET style + if input_messages: + evaluated_input = ctx.state.eval_if_expression(input_messages) + if evaluated_input: + if isinstance(evaluated_input, str): + messages.append(ChatMessage(role="user", text=evaluated_input)) + elif isinstance(evaluated_input, list): + for msg_item in evaluated_input: + if isinstance(msg_item, str): + messages.append(ChatMessage(role="user", text=msg_item)) + elif isinstance(msg_item, ChatMessage): + messages.append(msg_item) + elif isinstance(msg_item, dict) and "content" in msg_item: + item_dict = cast(dict[str, Any], msg_item) + role: str = str(item_dict.get("role", "user")) + content: str = str(item_dict.get("content", "")) + if role == "user": + messages.append(ChatMessage(role="user", text=content)) + elif role == "assistant": + messages.append(ChatMessage(role="assistant", text=content)) + elif role == "system": + messages.append(ChatMessage(role="system", text=content)) + + # Evaluate and include input arguments + evaluated_args: dict[str, Any] = {} + for arg_key, arg_value in input_arguments.items(): + evaluated_args[arg_key] = ctx.state.eval_if_expression(arg_value) + + # Prepare tool bindings + tool_bindings: dict[str, dict[str, Any]] = {} + for tool_config in tools_config: + tool_name: str | None = str(tool_config.get("name")) if tool_config.get("name") else None + bindings: list[dict[str, Any]] = list(tool_config.get("bindings", [])) # type: ignore[arg-type] + if tool_name and bindings: + tool_bindings[tool_name] = { + str(b.get("name")): ctx.state.eval_if_expression(b.get("input")) for b in bindings if b.get("name") + } + + logger.debug(f"InvokeAzureAgent: calling '{agent_name}' with {len(messages)} messages") + + # External loop iteration counter + iteration = 0 + max_iterations = 100 # Safety limit + + # Start external loop if configured + while True: + # Invoke the agent + try: + # Check if agent supports streaming + if hasattr(agent, "run_stream"): + full_text = "" + all_messages: list[Any] = [] + tool_calls: list[Any] = [] + + async for chunk in agent.run_stream(messages): + # Handle different chunk types + if hasattr(chunk, "text") and chunk.text: + full_text += chunk.text + yield AgentStreamingChunkEvent( + agent_name=str(agent_name), + chunk=chunk.text, + ) + + # Collect messages and tool calls + if hasattr(chunk, "messages"): + all_messages.extend(chunk.messages) + if hasattr(chunk, "tool_calls"): + tool_calls.extend(chunk.tool_calls) + + # Update state with result + ctx.state.set_agent_result( + text=full_text, + messages=all_messages, + tool_calls=tool_calls if tool_calls else None, + ) + + # Add to conversation history + if full_text: + ctx.state.add_conversation_message(ChatMessage(role="assistant", text=full_text)) + + # Store in output variables (.NET style) + if output_messages_var: + output_path_mapped = _map_variable_to_path(output_messages_var) + ctx.state.set(output_path_mapped, all_messages if all_messages else full_text) + + if output_response_obj_var: + output_path_mapped = _map_variable_to_path(output_response_obj_var) + # Try to parse as JSON if it looks like structured output + try: + parsed = json.loads(full_text) if full_text else None + ctx.state.set(output_path_mapped, parsed) + except (json.JSONDecodeError, TypeError): + ctx.state.set(output_path_mapped, full_text) + + # Store in output path (Python style) + if output_path: + ctx.state.set(output_path, full_text) + + yield AgentResponseEvent( + agent_name=str(agent_name), + text=full_text, + messages=all_messages, + tool_calls=tool_calls if tool_calls else None, + ) + + elif hasattr(agent, "run"): + # Non-streaming invocation + response = await agent.run(messages) + + text = _extract_text_from_response(response) + response_messages_attr = getattr(response, "messages", None) + response_messages: list[Any] = [] + if response_messages_attr: + response_messages = list(response_messages_attr) + response_tool_calls: list[Any] | None = getattr(response, "tool_calls", None) + + # Update state with result + ctx.state.set_agent_result( + text=text, + messages=response_messages, + tool_calls=response_tool_calls, + ) + + # Add to conversation history + if text: + ctx.state.add_conversation_message(ChatMessage(role="assistant", text=text)) + + # Store in output variables (.NET style) + if output_messages_var: + output_path_mapped = _map_variable_to_path(output_messages_var) + ctx.state.set(output_path_mapped, response_messages if response_messages else text) + + if output_response_obj_var: + output_path_mapped = _map_variable_to_path(output_response_obj_var) + try: + parsed = json.loads(text) if text else None + ctx.state.set(output_path_mapped, parsed) + except (json.JSONDecodeError, TypeError): + ctx.state.set(output_path_mapped, text) + + # Store in output path (Python style) + if output_path: + ctx.state.set(output_path, text) + + yield AgentResponseEvent( + agent_name=str(agent_name), + text=text, + messages=response_messages, + tool_calls=response_tool_calls, + ) + else: + logger.error(f"InvokeAzureAgent: agent '{agent_name}' has no run or run_stream method") + break + + except Exception as e: + logger.error(f"InvokeAzureAgent: error invoking agent '{agent_name}': {e}") + raise + + # Check external loop condition + if external_loop_when: + # Evaluate the loop condition + should_continue = ctx.state.eval(external_loop_when) + should_continue = bool(should_continue) if should_continue is not None else False + + logger.debug( + f"InvokeAzureAgent: external loop condition '{str(external_loop_when)[:50]}' = " + f"{should_continue} (iteration {iteration})" + ) + + if should_continue and iteration < max_iterations: + # Emit event to signal waiting for external input + action_id: str = str(ctx.action.get("id", f"agent_{agent_name}")) + yield ExternalLoopEvent( + action_id=action_id, + iteration=iteration, + condition_expression=str(external_loop_when), + ) + + # The workflow executor should: + # 1. Pause execution + # 2. Wait for external input + # 3. Update state with input + # 4. Resume this generator + + # For now, we request input via QuestionRequest + yield QuestionRequest( + request_id=f"{action_id}_input_{iteration}", + prompt="Waiting for user input...", + variable="turn.userInput", + ) + + iteration += 1 + + # Clear messages for next iteration (start fresh with conversation) + messages = _build_messages_from_state(ctx) + continue + elif iteration >= max_iterations: + logger.warning(f"InvokeAzureAgent: external loop exceeded max iterations ({max_iterations})") + + # No external loop or condition is false - exit + break + + +def _map_variable_to_path(variable: str) -> str: + """Map .NET-style variable names to state paths. + + Args: + variable: Variable name like 'Local.X' or 'System.ConversationId' + + Returns: + State path like 'turn.X' or 'system.ConversationId' + """ + if variable.startswith("Local."): + return "turn." + variable[6:] + if variable.startswith("System."): + return "system." + variable[7:] + if variable.startswith("Workflow."): + return "workflow." + variable[9:] + if "." in variable: + return variable + return "turn." + variable + + +@action_handler("InvokePromptAgent") +async def handle_invoke_prompt_agent(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: + """Invoke a local prompt-based agent (similar to InvokeAzureAgent but for local agents). + + Action schema: + kind: InvokePromptAgent + agent: agentName # name of the agent in the agents registry + input: =expression or literal input + instructions: =expression or literal prompt/instructions + outputPath: turn.response # optional path to store result + """ + # Implementation is similar to InvokeAzureAgent + # The difference is primarily in how the agent is configured + agent_name_raw = ctx.action.get("agent") + if not isinstance(agent_name_raw, str): + logger.warning("InvokePromptAgent action missing 'agent' property") + return + agent_name: str = agent_name_raw + input_expr = ctx.action.get("input") + instructions = ctx.action.get("instructions") + output_path = ctx.action.get("outputPath") + + # Get the agent from registry + agent = ctx.agents.get(agent_name) + if agent is None: + logger.error(f"InvokePromptAgent: agent '{agent_name}' not found in registry") + return + + # Evaluate input + input_value = ctx.state.eval_if_expression(input_expr) if input_expr else None + + # Evaluate instructions (unused currently but may be used for prompting) + _ = ctx.state.eval_if_expression(instructions) if instructions else None + + # Build messages + messages = _build_messages_from_state(ctx) + + # Add input as user message if provided + if input_value: + if isinstance(input_value, str): + messages.append(ChatMessage(role="user", text=input_value)) + elif isinstance(input_value, ChatMessage): + messages.append(input_value) + + logger.debug(f"InvokePromptAgent: calling '{agent_name}' with {len(messages)} messages") + + # Invoke the agent + try: + if hasattr(agent, "run_stream"): + full_text = "" + all_messages: list[Any] = [] + + async for chunk in agent.run_stream(messages): + if hasattr(chunk, "text") and chunk.text: + full_text += chunk.text + yield AgentStreamingChunkEvent( + agent_name=agent_name, + chunk=chunk.text, + ) + + if hasattr(chunk, "messages"): + all_messages.extend(chunk.messages) + + ctx.state.set_agent_result(text=full_text, messages=all_messages) + + if full_text: + ctx.state.add_conversation_message(ChatMessage(role="assistant", text=full_text)) + + if output_path: + ctx.state.set(output_path, full_text) + + yield AgentResponseEvent( + agent_name=agent_name, + text=full_text, + messages=all_messages, + ) + + elif hasattr(agent, "run"): + response = await agent.run(messages) + text = _extract_text_from_response(response) + response_messages = getattr(response, "messages", []) + + ctx.state.set_agent_result(text=text, messages=response_messages) + + if text: + ctx.state.add_conversation_message(ChatMessage(role="assistant", text=text)) + + if output_path: + ctx.state.set(output_path, text) + + yield AgentResponseEvent( + agent_name=agent_name, + text=text, + messages=response_messages, + ) + else: + logger.error(f"InvokePromptAgent: agent '{agent_name}' has no run or run_stream method") + + except Exception as e: + logger.error(f"InvokePromptAgent: error invoking agent '{agent_name}': {e}") + raise diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_actions_basic.py b/python/packages/declarative/agent_framework_declarative/_workflows/_actions_basic.py new file mode 100644 index 0000000000..0d942fdd1d --- /dev/null +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_actions_basic.py @@ -0,0 +1,575 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Basic action handlers for variable manipulation and output. + +This module implements handlers for: +- SetValue: Set a variable in the workflow state +- AppendValue: Append a value to a list variable +- SendActivity: Send text or attachments to the user +- EmitEvent: Emit a custom workflow event + +Note: All handlers are defined as async generators (AsyncGenerator[WorkflowEvent, None]) +for consistency with the ActionHandler protocol, even when they don't perform async +operations. This uniform interface allows the workflow executor to consume all handlers +the same way, and some handlers (like InvokeAzureAgent) genuinely require async for +network calls. The `return; yield` pattern makes a function an async generator without +actually yielding any events. +""" + +from collections.abc import AsyncGenerator +from typing import TYPE_CHECKING, Any, cast + +from agent_framework import get_logger + +from ._handlers import ( + ActionContext, + AttachmentOutputEvent, + CustomEvent, + TextOutputEvent, + WorkflowEvent, + action_handler, +) + +if TYPE_CHECKING: + from ._state import WorkflowState + +logger = get_logger("agent_framework.declarative.workflows.actions") + + +@action_handler("SetValue") +async def handle_set_value(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: # noqa: RUF029 + """Set a value in the workflow state. + + Action schema: + kind: SetValue + path: turn.variableName # or workflow.outputs.result + value: =expression or literal value + """ + path = ctx.action.get("path") + value = ctx.action.get("value") + + if not path: + logger.warning("SetValue action missing 'path' property") + return + + # Evaluate the value if it's an expression + evaluated_value = ctx.state.eval_if_expression(value) + + logger.debug(f"SetValue: {path} = {evaluated_value}") + ctx.state.set(path, evaluated_value) + + return + yield # Make it a generator + + +@action_handler("SetVariable") +async def handle_set_variable(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: # noqa: RUF029 + """Set a variable in the workflow state (.NET workflow format). + + This is an alias for SetValue with 'variable' instead of 'path'. + + Action schema: + kind: SetVariable + variable: Local.variableName + value: =expression or literal value + """ + variable = ctx.action.get("variable") + value = ctx.action.get("value") + + if not variable: + logger.warning("SetVariable action missing 'variable' property") + return + + # Evaluate the value if it's an expression + evaluated_value = ctx.state.eval_if_expression(value) + + # Map .NET-style variable names to Python state paths + # Local.X -> turn.X, System.X -> system.X, etc. + path = _map_variable_name_to_path(variable) + + logger.debug(f"SetVariable: {variable} ({path}) = {evaluated_value}") + ctx.state.set(path, evaluated_value) + + return + yield # Make it a generator + + +def _map_variable_name_to_path(variable: str) -> str: + """Map .NET-style variable names to state paths. + + Args: + variable: Variable name like 'Local.X' or 'System.ConversationId' + + Returns: + State path like 'turn.X' or 'system.ConversationId' + """ + if variable.startswith("Local."): + return "turn." + variable[6:] + if variable.startswith("System."): + return "system." + variable[7:] + if variable.startswith("Workflow."): + return "workflow." + variable[9:] + if "." in variable: + # Already has a namespace + return variable + # Default to turn scope + return "turn." + variable + + +@action_handler("AppendValue") +async def handle_append_value(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: # noqa: RUF029 + """Append a value to a list in the workflow state. + + Action schema: + kind: AppendValue + path: turn.results + value: =expression or literal value + """ + path = ctx.action.get("path") + value = ctx.action.get("value") + + if not path: + logger.warning("AppendValue action missing 'path' property") + return + + # Evaluate the value if it's an expression + evaluated_value = ctx.state.eval_if_expression(value) + + logger.debug(f"AppendValue: {path} += {evaluated_value}") + ctx.state.append(path, evaluated_value) + + return + yield # Make it a generator + + +@action_handler("SendActivity") +async def handle_send_activity(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: # noqa: RUF029 + """Send text or attachments to the user. + + Action schema (object form): + kind: SendActivity + activity: + text: =expression or literal text + attachments: + - content: ... + contentType: text/plain + + Action schema (simple form): + kind: SendActivity + activity: =expression or literal text + """ + activity = ctx.action.get("activity", {}) + + # Handle simple string form + if isinstance(activity, str): + evaluated_text = ctx.state.eval_if_expression(activity) + if evaluated_text: + logger.debug( + "SendActivity: text = %s", evaluated_text[:100] if len(str(evaluated_text)) > 100 else evaluated_text + ) + yield TextOutputEvent(text=str(evaluated_text)) + return + + # Handle object form - text output + text = activity.get("text") + if text: + evaluated_text = ctx.state.eval_if_expression(text) + if evaluated_text: + logger.debug( + "SendActivity: text = %s", evaluated_text[:100] if len(str(evaluated_text)) > 100 else evaluated_text + ) + yield TextOutputEvent(text=str(evaluated_text)) + + # Handle attachments + attachments = activity.get("attachments", []) + for attachment in attachments: + content = attachment.get("content") + content_type = attachment.get("contentType", "application/octet-stream") + + if content: + evaluated_content = ctx.state.eval_if_expression(content) + logger.debug(f"SendActivity: attachment type={content_type}") + yield AttachmentOutputEvent(content=evaluated_content, content_type=content_type) + + +@action_handler("EmitEvent") +async def handle_emit_event(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: # noqa: RUF029 + """Emit a custom workflow event. + + Action schema: + kind: EmitEvent + event: + name: eventName + data: =expression or literal data + """ + event_def = ctx.action.get("event", {}) + name = event_def.get("name") + data = event_def.get("data") + + if not name: + logger.warning("EmitEvent action missing 'event.name' property") + return + + # Evaluate data if it's an expression + evaluated_data = ctx.state.eval_if_expression(data) + + logger.debug(f"EmitEvent: {name} = {evaluated_data}") + yield CustomEvent(name=name, data=evaluated_data) + + +def _evaluate_dict_values(d: dict[str, Any], state: "WorkflowState") -> dict[str, Any]: + """Recursively evaluate PowerFx expressions in a dictionary. + + Args: + d: Dictionary that may contain expression values + state: The workflow state for expression evaluation + + Returns: + Dictionary with all expressions evaluated + """ + result: dict[str, Any] = {} + for key, value in d.items(): + if isinstance(value, str): + result[key] = state.eval_if_expression(value) + elif isinstance(value, dict): + result[key] = _evaluate_dict_values(cast(dict[str, Any], value), state) + elif isinstance(value, list): + evaluated_list: list[Any] = [] + for list_item in value: + if isinstance(list_item, dict): + evaluated_list.append(_evaluate_dict_values(cast(dict[str, Any], list_item), state)) + elif isinstance(list_item, str): + evaluated_list.append(state.eval_if_expression(list_item)) + else: + evaluated_list.append(list_item) + result[key] = evaluated_list + else: + result[key] = value + return result + + +@action_handler("SetTextVariable") +async def handle_set_text_variable(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: # noqa: RUF029 + """Set a text variable with string interpolation support. + + This is similar to SetVariable but supports multi-line text with + {Local.Variable} style interpolation. + + Action schema: + kind: SetTextVariable + variable: Local.myText + value: |- + Multi-line text with {Local.Variable} interpolation + and more content here. + """ + variable = ctx.action.get("variable") + value = ctx.action.get("value") + + if not variable: + logger.warning("SetTextVariable action missing 'variable' property") + return + + # Evaluate the value - handle string interpolation + if isinstance(value, str): + evaluated_value = _interpolate_string(value, ctx.state) + else: + evaluated_value = ctx.state.eval_if_expression(value) + + path = _map_variable_name_to_path(variable) + + logger.debug(f"SetTextVariable: {variable} ({path}) = {str(evaluated_value)[:100]}") + ctx.state.set(path, evaluated_value) + + return + yield # Make it a generator + + +@action_handler("SetMultipleVariables") +async def handle_set_multiple_variables(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: # noqa: RUF029 + """Set multiple variables at once. + + Action schema: + kind: SetMultipleVariables + variables: + - variable: Local.var1 + value: value1 + - variable: Local.var2 + value: =expression + """ + variables = ctx.action.get("variables", []) + + for var_def in variables: + variable = var_def.get("variable") + value = var_def.get("value") + + if not variable: + logger.warning("SetMultipleVariables: variable entry missing 'variable' property") + continue + + evaluated_value = ctx.state.eval_if_expression(value) + path = _map_variable_name_to_path(variable) + + logger.debug(f"SetMultipleVariables: {variable} ({path}) = {evaluated_value}") + ctx.state.set(path, evaluated_value) + + return + yield # Make it a generator + + +@action_handler("ResetVariable") +async def handle_reset_variable(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: # noqa: RUF029 + """Reset a variable to its default/blank state. + + Action schema: + kind: ResetVariable + variable: Local.variableName + """ + variable = ctx.action.get("variable") + + if not variable: + logger.warning("ResetVariable action missing 'variable' property") + return + + path = _map_variable_name_to_path(variable) + + logger.debug(f"ResetVariable: {variable} ({path}) = None") + ctx.state.set(path, None) + + return + yield # Make it a generator + + +@action_handler("ClearAllVariables") +async def handle_clear_all_variables(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: # noqa: RUF029 + """Clear all turn-scoped variables. + + Action schema: + kind: ClearAllVariables + """ + logger.debug("ClearAllVariables: clearing turn scope") + ctx.state.reset_turn() + + return + yield # Make it a generator + + +@action_handler("CreateConversation") +async def handle_create_conversation(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: # noqa: RUF029 + """Create a new conversation context. + + Action schema (.NET style): + kind: CreateConversation + conversationId: Local.myConversationId # Variable to store the generated ID + + The conversationId parameter is the OUTPUT variable where the generated + conversation ID will be stored. This matches .NET behavior where: + - A unique conversation ID is always auto-generated + - The conversationId parameter specifies where to store it + """ + import uuid + + conversation_id_var = ctx.action.get("conversationId") + + # Always generate a unique ID (.NET behavior) + generated_id = str(uuid.uuid4()) + + # Store conversation in state + conversations: dict[str, Any] = ctx.state.get("system.conversations") or {} + conversations[generated_id] = { + "id": generated_id, + "messages": [], + "created_at": None, # Could add timestamp + } + ctx.state.set("system.conversations", conversations) + + logger.debug(f"CreateConversation: created {generated_id}") + + # Store the generated ID in the specified variable (.NET style output binding) + if conversation_id_var: + output_path = _map_variable_name_to_path(conversation_id_var) + ctx.state.set(output_path, generated_id) + logger.debug(f"CreateConversation: bound to {output_path} = {generated_id}") + + # Also handle legacy output binding for backwards compatibility + output = ctx.action.get("output", {}) + output_var = output.get("conversationId") + if output_var: + output_path = _map_variable_name_to_path(output_var) + ctx.state.set(output_path, generated_id) + logger.debug(f"CreateConversation: legacy output bound to {output_path}") + + return + yield # Make it a generator + + +@action_handler("AddConversationMessage") +async def handle_add_conversation_message(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: # noqa: RUF029 + """Add a message to a conversation. + + Action schema: + kind: AddConversationMessage + conversationId: =expression or variable reference + message: + role: user | assistant | system + content: =expression or literal text + """ + conversation_id = ctx.action.get("conversationId") + message_def = ctx.action.get("message", {}) + + if not conversation_id: + logger.warning("AddConversationMessage missing 'conversationId' property") + return + + # Evaluate conversation ID + evaluated_id = ctx.state.eval_if_expression(conversation_id) + + # Evaluate message content + role = message_def.get("role", "user") + content = message_def.get("content", "") + + evaluated_content = ctx.state.eval_if_expression(content) + if isinstance(evaluated_content, str): + evaluated_content = _interpolate_string(evaluated_content, ctx.state) + + # Get or create conversation + conversations: dict[str, Any] = ctx.state.get("system.conversations") or {} + if evaluated_id not in conversations: + conversations[evaluated_id] = {"id": evaluated_id, "messages": []} + + # Add message + message: dict[str, Any] = {"role": role, "content": evaluated_content} + conv_entry: dict[str, Any] = dict(conversations[evaluated_id]) + messages_list: list[Any] = list(conv_entry.get("messages", [])) + messages_list.append(message) + conv_entry["messages"] = messages_list + conversations[evaluated_id] = conv_entry + ctx.state.set("system.conversations", conversations) + + # Also add to global conversation state + ctx.state.add_conversation_message(message) + + logger.debug(f"AddConversationMessage: added {role} message to {evaluated_id}") + + return + yield # Make it a generator + + +@action_handler("CopyConversationMessages") +async def handle_copy_conversation_messages(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: # noqa: RUF029 + """Copy messages from one conversation to another. + + Action schema: + kind: CopyConversationMessages + sourceConversationId: =expression + targetConversationId: =expression + count: 10 # optional, number of messages to copy + """ + source_id = ctx.action.get("sourceConversationId") + target_id = ctx.action.get("targetConversationId") + count = ctx.action.get("count") + + if not source_id or not target_id: + logger.warning("CopyConversationMessages missing source or target conversation ID") + return + + # Evaluate IDs + evaluated_source = ctx.state.eval_if_expression(source_id) + evaluated_target = ctx.state.eval_if_expression(target_id) + + # Get conversations + conversations: dict[str, Any] = ctx.state.get("system.conversations") or {} + + source_conv: dict[str, Any] = conversations.get(evaluated_source, {}) + source_messages: list[Any] = source_conv.get("messages", []) + + # Limit messages if count specified + if count is not None: + source_messages = source_messages[-count:] + + # Get or create target conversation + if evaluated_target not in conversations: + conversations[evaluated_target] = {"id": evaluated_target, "messages": []} + + # Copy messages + target_entry: dict[str, Any] = dict(conversations[evaluated_target]) + target_messages: list[Any] = list(target_entry.get("messages", [])) + target_messages.extend(source_messages) + target_entry["messages"] = target_messages + conversations[evaluated_target] = target_entry + ctx.state.set("system.conversations", conversations) + + logger.debug( + "CopyConversationMessages: copied %d messages from %s to %s", + len(source_messages), + evaluated_source, + evaluated_target, + ) + + return + yield # Make it a generator + + +@action_handler("RetrieveConversationMessages") +async def handle_retrieve_conversation_messages(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: # noqa: RUF029 + """Retrieve messages from a conversation and store in a variable. + + Action schema: + kind: RetrieveConversationMessages + conversationId: =expression + output: + messages: Local.myMessages + count: 10 # optional + """ + conversation_id = ctx.action.get("conversationId") + output = ctx.action.get("output", {}) + count = ctx.action.get("count") + + if not conversation_id: + logger.warning("RetrieveConversationMessages missing 'conversationId' property") + return + + # Evaluate conversation ID + evaluated_id = ctx.state.eval_if_expression(conversation_id) + + # Get messages + conversations: dict[str, Any] = ctx.state.get("system.conversations") or {} + conv: dict[str, Any] = conversations.get(evaluated_id, {}) + messages: list[Any] = conv.get("messages", []) + + # Limit messages if count specified + if count is not None: + messages = messages[-count:] + + # Handle output binding + output_var = output.get("messages") + if output_var: + output_path = _map_variable_name_to_path(output_var) + ctx.state.set(output_path, messages) + logger.debug(f"RetrieveConversationMessages: bound {len(messages)} messages to {output_path}") + + return + yield # Make it a generator + + +def _interpolate_string(text: str, state: "WorkflowState") -> str: + """Interpolate {Variable.Path} references in a string. + + Args: + text: Text that may contain {Variable.Path} references + state: The workflow state for variable lookup + + Returns: + Text with variables interpolated + """ + import re + + def replace_var(match: re.Match[str]) -> str: + var_path: str = match.group(1) + # Map .NET style to Python style + path = _map_variable_name_to_path(var_path) + value = state.get(path) + return str(value) if value is not None else "" + + # Match {Variable.Path} patterns + pattern = r"\{([A-Za-z][A-Za-z0-9_.]*)\}" + return re.sub(pattern, replace_var, text) diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_actions_control_flow.py b/python/packages/declarative/agent_framework_declarative/_workflows/_actions_control_flow.py new file mode 100644 index 0000000000..e7bc6597bc --- /dev/null +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_actions_control_flow.py @@ -0,0 +1,397 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Control flow action handlers for declarative workflows. + +This module implements handlers for: +- Foreach: Iterate over a collection and execute nested actions +- If: Conditional branching +- Switch: Multi-way branching based on value matching +- RepeatUntil: Loop until a condition is met +- BreakLoop: Exit the current loop +- ContinueLoop: Skip to the next iteration +""" + +from collections.abc import AsyncGenerator + +from agent_framework import get_logger + +from ._handlers import ( + ActionContext, + LoopControlSignal, + WorkflowEvent, + action_handler, +) + +logger = get_logger("agent_framework.declarative.workflows.actions") + + +@action_handler("Foreach") +async def handle_foreach(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: + """Iterate over a collection and execute nested actions for each item. + + Action schema: + kind: Foreach + source: =expression returning a collection + itemName: itemVariable # optional, defaults to 'item' + indexName: indexVariable # optional, defaults to 'index' + actions: + - kind: ... + """ + source_expr = ctx.action.get("source") + item_name = ctx.action.get("itemName", "item") + index_name = ctx.action.get("indexName", "index") + actions = ctx.action.get("actions", []) + + if not source_expr: + logger.warning("Foreach action missing 'source' property") + return + + # Evaluate the source collection + collection = ctx.state.eval_if_expression(source_expr) + + if collection is None: + logger.debug("Foreach: source evaluated to None, skipping") + return + + if not hasattr(collection, "__iter__"): + logger.warning(f"Foreach: source is not iterable: {type(collection).__name__}") + return + + collection_len = len(list(collection)) if hasattr(collection, "__len__") else "?" + logger.debug(f"Foreach: iterating over {collection_len} items") + + # Iterate over the collection + for index, item in enumerate(collection): + # Set loop variables in the turn scope + ctx.state.set(f"turn.{item_name}", item) + ctx.state.set(f"turn.{index_name}", index) + + # Execute nested actions + try: + async for event in ctx.execute_actions(actions, ctx.state): + # Check for loop control signals + if isinstance(event, LoopControlSignal): + if event.signal_type == "break": + logger.debug(f"Foreach: break signal received at index {index}") + return + elif event.signal_type == "continue": + logger.debug(f"Foreach: continue signal received at index {index}") + break # Break inner loop to continue outer + else: + yield event + except StopIteration: + # Continue signal was raised + continue + + +@action_handler("If") +async def handle_if(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: + """Conditional branching based on a condition expression. + + Action schema: + kind: If + condition: =boolean expression + then: + - kind: ... # actions if condition is true + else: + - kind: ... # actions if condition is false (optional) + """ + condition_expr = ctx.action.get("condition") + then_actions = ctx.action.get("then", []) + else_actions = ctx.action.get("else", []) + + if condition_expr is None: + logger.warning("If action missing 'condition' property") + return + + # Evaluate the condition + condition_result = ctx.state.eval_if_expression(condition_expr) + + # Coerce to boolean + is_truthy = bool(condition_result) + + logger.debug( + "If: condition '%s' evaluated to %s", + condition_expr[:50] if len(str(condition_expr)) > 50 else condition_expr, + is_truthy, + ) + + # Execute the appropriate branch + actions_to_execute = then_actions if is_truthy else else_actions + + async for event in ctx.execute_actions(actions_to_execute, ctx.state): + yield event + + +@action_handler("Switch") +async def handle_switch(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: + """Multi-way branching based on value matching. + + Action schema: + kind: Switch + value: =expression to match + cases: + - match: value1 + actions: + - kind: ... + - match: value2 + actions: + - kind: ... + default: + - kind: ... # optional default actions + """ + value_expr = ctx.action.get("value") + cases = ctx.action.get("cases", []) + default_actions = ctx.action.get("default", []) + + if not value_expr: + logger.warning("Switch action missing 'value' property") + return + + # Evaluate the switch value + switch_value = ctx.state.eval_if_expression(value_expr) + + logger.debug(f"Switch: value = {switch_value}") + + # Find matching case + matched_actions = None + for case in cases: + match_value = ctx.state.eval_if_expression(case.get("match")) + if switch_value == match_value: + matched_actions = case.get("actions", []) + logger.debug(f"Switch: matched case '{match_value}'") + break + + # Use default if no match found + if matched_actions is None: + matched_actions = default_actions + logger.debug("Switch: using default case") + + # Execute matched actions + async for event in ctx.execute_actions(matched_actions, ctx.state): + yield event + + +@action_handler("RepeatUntil") +async def handle_repeat_until(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: + """Loop until a condition becomes true. + + Action schema: + kind: RepeatUntil + condition: =boolean expression (loop exits when true) + maxIterations: 100 # optional safety limit + actions: + - kind: ... + """ + condition_expr = ctx.action.get("condition") + max_iterations = ctx.action.get("maxIterations", 100) + actions = ctx.action.get("actions", []) + + if condition_expr is None: + logger.warning("RepeatUntil action missing 'condition' property") + return + + iteration = 0 + while iteration < max_iterations: + iteration += 1 + ctx.state.set("turn.iteration", iteration) + + logger.debug(f"RepeatUntil: iteration {iteration}") + + # Execute loop body + should_break = False + async for event in ctx.execute_actions(actions, ctx.state): + if isinstance(event, LoopControlSignal): + if event.signal_type == "break": + logger.debug(f"RepeatUntil: break signal received at iteration {iteration}") + should_break = True + break + elif event.signal_type == "continue": + logger.debug(f"RepeatUntil: continue signal received at iteration {iteration}") + break + else: + yield event + + if should_break: + break + + # Check exit condition + condition_result = ctx.state.eval_if_expression(condition_expr) + if bool(condition_result): + logger.debug(f"RepeatUntil: condition met after {iteration} iterations") + break + + if iteration >= max_iterations: + logger.warning(f"RepeatUntil: reached max iterations ({max_iterations})") + + +@action_handler("BreakLoop") +async def handle_break_loop(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: # noqa: RUF029 + """Signal to break out of the current loop. + + Action schema: + kind: BreakLoop + """ + logger.debug("BreakLoop: signaling break") + yield LoopControlSignal(signal_type="break") + + +@action_handler("ContinueLoop") +async def handle_continue_loop(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: # noqa: RUF029 + """Signal to continue to the next iteration of the current loop. + + Action schema: + kind: ContinueLoop + """ + logger.debug("ContinueLoop: signaling continue") + yield LoopControlSignal(signal_type="continue") + + +@action_handler("ConditionGroup") +async def handle_condition_group(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: + """Multi-condition branching (like else-if chains). + + Evaluates conditions in order and executes the first matching condition's actions. + If no conditions match and elseActions is provided, executes those. + + Action schema: + kind: ConditionGroup + conditions: + - condition: =boolean expression + actions: + - kind: ... + - condition: =another expression + actions: + - kind: ... + elseActions: + - kind: ... # optional, executed if no conditions match + """ + conditions = ctx.action.get("conditions", []) + else_actions = ctx.action.get("elseActions", []) + + matched = False + for condition_def in conditions: + condition_expr = condition_def.get("condition") + actions = condition_def.get("actions", []) + + if condition_expr is None: + logger.warning("ConditionGroup condition missing 'condition' property") + continue + + # Evaluate the condition + condition_result = ctx.state.eval_if_expression(condition_expr) + is_truthy = bool(condition_result) + + logger.debug( + "ConditionGroup: condition '%s' evaluated to %s", + str(condition_expr)[:50] if len(str(condition_expr)) > 50 else condition_expr, + is_truthy, + ) + + if is_truthy: + matched = True + # Execute this condition's actions + async for event in ctx.execute_actions(actions, ctx.state): + yield event + # Only execute the first matching condition + break + + # Execute elseActions if no condition matched + if not matched and else_actions: + logger.debug("ConditionGroup: no conditions matched, executing elseActions") + async for event in ctx.execute_actions(else_actions, ctx.state): + yield event + + +@action_handler("GotoAction") +async def handle_goto_action(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: # noqa: RUF029 + """Jump to another action by ID (triggers re-execution from that action). + + Note: GotoAction in the .NET implementation creates a loop by restarting + execution from a specific action. In Python, we emit a GotoSignal that + the top-level executor should handle. + + Action schema: + kind: GotoAction + actionId: target_action_id + """ + action_id = ctx.action.get("actionId") + + if not action_id: + logger.warning("GotoAction missing 'actionId' property") + return + + logger.debug(f"GotoAction: jumping to action '{action_id}'") + + # Emit a goto signal that the executor should handle + yield GotoSignal(target_action_id=action_id) + + +class GotoSignal(WorkflowEvent): + """Signal to jump to a specific action by ID. + + This signal is used by GotoAction to implement control flow jumps. + The top-level executor should handle this signal appropriately. + """ + + def __init__(self, target_action_id: str) -> None: + self.target_action_id = target_action_id + + +class EndWorkflowSignal(WorkflowEvent): + """Signal to end the workflow execution. + + This signal causes the workflow to terminate gracefully. + """ + + def __init__(self, reason: str | None = None) -> None: + self.reason = reason + + +class EndConversationSignal(WorkflowEvent): + """Signal to end the current conversation. + + This signal causes the conversation to terminate while the workflow may continue. + """ + + def __init__(self, conversation_id: str | None = None, reason: str | None = None) -> None: + self.conversation_id = conversation_id + self.reason = reason + + +@action_handler("EndWorkflow") +async def handle_end_workflow(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: # noqa: RUF029 + """End the workflow execution. + + Action schema: + kind: EndWorkflow + reason: Optional reason for ending (for logging) + """ + reason = ctx.action.get("reason") + + logger.debug(f"EndWorkflow: ending workflow{f' (reason: {reason})' if reason else ''}") + + yield EndWorkflowSignal(reason=reason) + + +@action_handler("EndConversation") +async def handle_end_conversation(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: # noqa: RUF029 + """End the current conversation. + + Action schema: + kind: EndConversation + conversationId: Optional specific conversation to end + reason: Optional reason for ending + """ + conversation_id = ctx.action.get("conversationId") + reason = ctx.action.get("reason") + + # Evaluate conversation ID if provided + if conversation_id: + evaluated_id = ctx.state.eval_if_expression(conversation_id) + else: + evaluated_id = ctx.state.get("system.ConversationId") + + logger.debug(f"EndConversation: ending conversation {evaluated_id}{f' (reason: {reason})' if reason else ''}") + + yield EndConversationSignal(conversation_id=evaluated_id, reason=reason) diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_actions_error.py b/python/packages/declarative/agent_framework_declarative/_workflows/_actions_error.py new file mode 100644 index 0000000000..334114ee64 --- /dev/null +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_actions_error.py @@ -0,0 +1,130 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Error handling action handlers for declarative workflows. + +This module implements handlers for: +- ThrowException: Raise an error that can be caught by TryCatch +- TryCatch: Try-catch-finally error handling +""" + +from collections.abc import AsyncGenerator +from dataclasses import dataclass + +from agent_framework import get_logger + +from ._handlers import ( + ActionContext, + WorkflowEvent, + action_handler, +) + +logger = get_logger("agent_framework.declarative.workflows.actions") + + +class WorkflowActionError(Exception): + """Exception raised by ThrowException action.""" + + def __init__(self, message: str, code: str | None = None): + super().__init__(message) + self.code = code + + +@dataclass +class ErrorEvent(WorkflowEvent): + """Event emitted when an error occurs.""" + + message: str + """The error message.""" + + code: str | None = None + """Optional error code.""" + + source_action: str | None = None + """The action that caused the error.""" + + +@action_handler("ThrowException") +async def handle_throw_exception(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: # noqa: RUF029 + """Raise an exception that can be caught by TryCatch. + + Action schema: + kind: ThrowException + message: =expression or literal error message + code: ERROR_CODE # optional error code + """ + message_expr = ctx.action.get("message", "An error occurred") + code = ctx.action.get("code") + + # Evaluate the message if it's an expression + message = ctx.state.eval_if_expression(message_expr) + + logger.debug(f"ThrowException: {message} (code={code})") + + raise WorkflowActionError(str(message), code) + + # This yield is never reached but makes it a generator + yield ErrorEvent(message=str(message), code=code) # type: ignore[unreachable] + + +@action_handler("TryCatch") +async def handle_try_catch(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: + """Try-catch-finally error handling. + + Action schema: + kind: TryCatch + try: + - kind: ... # actions to try + catch: + - kind: ... # actions to execute on error (optional) + finally: + - kind: ... # actions to always execute (optional) + + In the catch block, the following variables are available: + turn.error.message: The error message + turn.error.code: The error code (if provided) + turn.error.type: The error type name + """ + try_actions = ctx.action.get("try", []) + catch_actions = ctx.action.get("catch", []) + finally_actions = ctx.action.get("finally", []) + + error_occurred = False + error_info = None + + # Execute try block + try: + async for event in ctx.execute_actions(try_actions, ctx.state): + yield event + except WorkflowActionError as e: + error_occurred = True + error_info = { + "message": str(e), + "code": e.code, + "type": "WorkflowActionError", + } + logger.debug(f"TryCatch: caught WorkflowActionError: {e}") + except Exception as e: + error_occurred = True + error_info = { + "message": str(e), + "code": None, + "type": type(e).__name__, + } + logger.debug(f"TryCatch: caught {type(e).__name__}: {e}") + + # Execute catch block if error occurred + if error_occurred and catch_actions: + # Set error info in turn scope + ctx.state.set("turn.error", error_info) + + try: + async for event in ctx.execute_actions(catch_actions, ctx.state): + yield event + finally: + # Clean up error info (but don't interfere with finally block) + pass + + # Execute finally block + if finally_actions: + async for event in ctx.execute_actions(finally_actions, ctx.state): + yield event diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py new file mode 100644 index 0000000000..d0d24dbefd --- /dev/null +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py @@ -0,0 +1,744 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Base classes for graph-based declarative workflow executors. + +This module provides: +- DeclarativeWorkflowState: Manages workflow variables via SharedState +- DeclarativeActionExecutor: Base class for action executors +- Message types for inter-executor communication +""" + +import logging +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast + +from agent_framework._workflows import ( + Executor, + SharedState, + WorkflowContext, +) + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from collections.abc import Mapping + + +class ConversationData(TypedDict): + """Structure for conversation-related state data. + + Attributes: + messages: Active conversation messages for the current agent interaction. + This is the primary storage used by InvokeAgent actions. + history: Deprecated. Previously used as a separate history buffer, but + messages and history are now kept in sync. Use messages instead. + """ + + messages: list[Any] + history: list[Any] # Deprecated: use messages instead + + +class DeclarativeStateData(TypedDict, total=False): + """Structure for the declarative workflow state stored in SharedState. + + This TypedDict defines the schema for workflow variables stored + under the DECLARATIVE_STATE_KEY in SharedState. + + Attributes: + inputs: Initial workflow inputs (read-only after initialization). + outputs: Values to return from the workflow. + turn: Variables persisting within the current workflow turn. + system: System-level variables. + agent: Results from the most recent agent invocation. + conversation: Conversation history and messages. + custom: User-defined custom variables. + _declarative_loop_state: Internal loop iteration state (managed by ForeachExecutors). + """ + + inputs: dict[str, Any] + outputs: dict[str, Any] + turn: dict[str, Any] + system: dict[str, Any] + agent: dict[str, Any] + conversation: ConversationData + custom: dict[str, Any] + _declarative_loop_state: dict[str, Any] + + +# Key used in SharedState to store declarative workflow variables +DECLARATIVE_STATE_KEY = "_declarative_workflow_state" + +# Namespace prefix mappings from .NET style to Python style +_NAMESPACE_MAPPINGS = { + "Local.": "turn.", + "System.": "system.", + "Workflow.": "workflow.", + "inputs.": "workflow.inputs.", +} + + +def _map_dotnet_namespace(path: str) -> str: + """Map .NET-style namespace prefixes to Python-style. + + Args: + path: A dot-notated path like 'Local.value' or 'Workflow.inputs.query' + + Returns: + The path with any .NET-style prefix converted to Python-style + """ + for dotnet_prefix, python_prefix in _NAMESPACE_MAPPINGS.items(): + if path.startswith(dotnet_prefix): + return python_prefix + path[len(dotnet_prefix) :] + return path + + +class DeclarativeWorkflowState: + """Manages workflow variables stored in SharedState. + + This class provides the same interface as the interpreter-based WorkflowState + but stores all data in SharedState for checkpointing support. + + The state is organized into namespaces: + - workflow.inputs: Initial inputs (read-only) + - workflow.outputs: Values to return from workflow + - turn: Variables persisting within the workflow turn + - agent: Results from most recent agent invocation + - conversation: Conversation history + """ + + def __init__(self, shared_state: SharedState): + """Initialize with a SharedState instance. + + Args: + shared_state: The workflow's shared state for persistence + """ + self._shared_state = shared_state + + async def initialize(self, inputs: "Mapping[str, Any] | None" = None) -> None: + """Initialize the declarative state with inputs. + + Args: + inputs: Initial workflow inputs (become workflow.inputs.*) + """ + state_data: DeclarativeStateData = { + "inputs": dict(inputs) if inputs else {}, + "outputs": {}, + "turn": {}, + "system": {}, + "agent": {}, + "conversation": {"messages": [], "history": []}, + "custom": {}, + } + await self._shared_state.set(DECLARATIVE_STATE_KEY, state_data) + + async def get_state_data(self) -> DeclarativeStateData: + """Get the full state data dict from shared state.""" + try: + result: DeclarativeStateData = await self._shared_state.get(DECLARATIVE_STATE_KEY) + return result + except KeyError: + # Initialize if not present + await self.initialize() + return cast(DeclarativeStateData, await self._shared_state.get(DECLARATIVE_STATE_KEY)) + + async def set_state_data(self, data: DeclarativeStateData) -> None: + """Set the full state data dict in shared state.""" + await self._shared_state.set(DECLARATIVE_STATE_KEY, data) + + async def get(self, path: str, default: Any = None) -> Any: + """Get a value from the state using a dot-notated path. + + Args: + path: Dot-notated path like 'turn.results' or 'workflow.inputs.query' + default: Default value if path doesn't exist + + Returns: + The value at the path, or default if not found + """ + # Map .NET style namespaces to Python style + if path.startswith("Local."): + path = "turn." + path[6:] + elif path.startswith("System."): + path = "system." + path[7:] + elif path.startswith("Workflow."): + path = "workflow." + path[9:] + elif path.startswith("inputs."): + path = "workflow.inputs." + path[7:] + + state_data = await self.get_state_data() + parts = path.split(".") + if not parts: + return default + + namespace = parts[0] + remaining = parts[1:] + + # Handle workflow.inputs and workflow.outputs specially + if namespace == "workflow" and remaining: + sub_namespace = remaining[0] + remaining = remaining[1:] + if sub_namespace == "inputs": + obj: Any = state_data.get("inputs", {}) + elif sub_namespace == "outputs": + obj = state_data.get("outputs", {}) + else: + return default + elif namespace == "turn": + obj = state_data.get("turn", {}) + elif namespace == "system": + obj = state_data.get("system", {}) + elif namespace == "agent": + obj = state_data.get("agent", {}) + elif namespace == "conversation": + obj = state_data.get("conversation", {}) + else: + # Try custom namespace + custom_data: dict[str, Any] = state_data.get("custom", {}) + obj = custom_data.get(namespace, default) + if obj is default: + return default + + # Navigate the remaining path + for part in remaining: + if isinstance(obj, dict): + obj = obj.get(part, default) # type: ignore[union-attr] + if obj is default: + return default + elif hasattr(obj, part): # type: ignore[arg-type] + obj = getattr(obj, part) # type: ignore[arg-type] + else: + return default + + return obj # type: ignore[return-value] + + async def set(self, path: str, value: Any) -> None: + """Set a value in the state using a dot-notated path. + + Args: + path: Dot-notated path like 'turn.results' or 'workflow.outputs.response' + value: The value to set + + Raises: + ValueError: If attempting to set workflow.inputs (which is read-only) + """ + # Map .NET style namespaces to Python style + path = _map_dotnet_namespace(path) + + state_data = await self.get_state_data() + parts = path.split(".") + if not parts: + return + + namespace = parts[0] + remaining = parts[1:] + + # Determine target dict + if namespace == "workflow": + if not remaining: + raise ValueError("Cannot set 'workflow' directly; use 'workflow.outputs.*'") + sub_namespace = remaining[0] + remaining = remaining[1:] + if sub_namespace == "inputs": + raise ValueError("Cannot modify workflow.inputs - they are read-only") + if sub_namespace == "outputs": + target = state_data.setdefault("outputs", {}) + else: + raise ValueError(f"Unknown workflow namespace: {sub_namespace}") + elif namespace == "turn": + target = state_data.setdefault("turn", {}) + elif namespace == "system": + target = state_data.setdefault("system", {}) + elif namespace == "agent": + target = state_data.setdefault("agent", {}) + elif namespace == "conversation": + target = cast(dict[str, Any], state_data).setdefault("conversation", {}) + else: + # Create or use custom namespace + custom = state_data.setdefault("custom", {}) + if namespace not in custom: + custom[namespace] = {} + target = custom[namespace] + + if not remaining: + raise ValueError(f"Cannot replace entire namespace '{namespace}'") + + # Navigate to parent, creating dicts as needed + for part in remaining[:-1]: + if part not in target: + target[part] = {} + target = target[part] + + # Set the final value + target[remaining[-1]] = value + await self.set_state_data(state_data) + + async def append(self, path: str, value: Any) -> None: + """Append a value to a list at the specified path. + + If the path doesn't exist, creates a new list with the value. + + Note: This operation is not atomic. In concurrent scenarios, use explicit + locking or consider using atomic operations at the storage layer. + + Args: + path: Dot-notated path to a list + value: The value to append + """ + existing = await self.get(path) + if existing is None: + await self.set(path, [value]) + elif isinstance(existing, list): + existing_list: list[Any] = list(existing) # type: ignore[arg-type] + existing_list.append(value) + await self.set(path, existing_list) + else: + raise ValueError(f"Cannot append to non-list at path '{path}'") + + async def get_inputs(self) -> dict[str, Any]: + """Get the workflow inputs.""" + state_data = await self.get_state_data() + inputs: dict[str, Any] = state_data.get("inputs", {}) + return inputs + + async def get_outputs(self) -> dict[str, Any]: + """Get the workflow outputs.""" + state_data = await self.get_state_data() + outputs: dict[str, Any] = state_data.get("outputs", {}) + return outputs + + async def eval(self, expression: str) -> Any: + """Evaluate a PowerFx expression with the current state. + + Expressions starting with '=' are evaluated as PowerFx. + Other strings are returned as-is. + + Args: + expression: The expression to evaluate + + Returns: + The evaluated result + """ + if not expression: + return expression + + if not isinstance(expression, str): + return expression + + if not expression.startswith("="): + return expression + + # Strip the leading '=' for evaluation + formula = expression[1:] + + # Try PowerFx evaluation if available + try: + from powerfx import Engine + + engine = Engine() + symbols = await self._to_powerfx_symbols() + return engine.eval(formula, symbols=symbols) + except ImportError: + # powerfx package not installed - use simple fallback + logger.debug(f"PowerFx package not installed, using simple evaluation for: {formula}") + except Exception: + # PowerFx evaluation failed (syntax error, unsupported function, etc.) + # Fall back to simple evaluation which handles basic cases + logger.debug(f"PowerFx evaluation failed for '{formula}', falling back to simple evaluation") + + # Fallback to simple evaluation + return await self._eval_simple(formula) + + async def _to_powerfx_symbols(self) -> dict[str, Any]: + """Convert the current state to a PowerFx symbols dictionary.""" + state_data = await self.get_state_data() + return { + "workflow": { + "inputs": state_data.get("inputs", {}), + "outputs": state_data.get("outputs", {}), + }, + "turn": state_data.get("turn", {}), + "agent": state_data.get("agent", {}), + "conversation": state_data.get("conversation", {}), + **state_data.get("custom", {}), + } + + async def _eval_simple(self, formula: str) -> Any: + """Simple expression evaluation fallback.""" + from ._powerfx_functions import CUSTOM_FUNCTIONS + + formula = formula.strip() + + # Handle logical operators first (lowest precedence) + # Note: " And " and " Or " are case-sensitive in PowerFx + # We also handle common variations with newlines + for and_op in [" And ", "\n And ", " And\n", "\nAnd\n", "\nAnd ", " And\r\n", "\r\nAnd "]: + if and_op in formula: + # Split on first occurrence + idx = formula.find(and_op) + left_str = formula[:idx].strip() + right_str = formula[idx + len(and_op) :].strip() + left = await self._eval_simple(left_str) + right = await self._eval_simple(right_str) + return bool(left) and bool(right) + + for or_op in [" Or ", "\n Or ", " Or\n", "\nOr\n", "\nOr ", " Or\r\n", "\r\nOr "]: + if or_op in formula: + idx = formula.find(or_op) + left_str = formula[:idx].strip() + right_str = formula[idx + len(or_op) :].strip() + left = await self._eval_simple(left_str) + right = await self._eval_simple(right_str) + return bool(left) or bool(right) + + # Handle negation + if formula.startswith("!"): + inner = formula[1:].strip() + result = await self._eval_simple(inner) + return not bool(result) + + # Handle Not() function + if formula.startswith("Not(") and formula.endswith(")"): + inner = formula[4:-1].strip() + result = await self._eval_simple(inner) + return not bool(result) + + # Handle function calls + for func_name, func in CUSTOM_FUNCTIONS.items(): + if formula.startswith(f"{func_name}(") and formula.endswith(")"): + args_str = formula[len(func_name) + 1 : -1] + args = self._parse_function_args(args_str) + evaluated_args: list[Any] = [] + for arg in args: + if isinstance(arg, str): + evaluated_args.append(await self._eval_simple(arg)) + else: + evaluated_args.append(arg) + try: + return func(*evaluated_args) + except Exception: + return formula + + # Handle comparison operators + # Support both PowerFx style (=) and Python style (==) for equality + for op in [" < ", " > ", " <= ", " >= ", " <> ", " != ", " == ", " = "]: + if op in formula: + parts = formula.split(op, 1) + left = await self._eval_simple(parts[0].strip()) + right = await self._eval_simple(parts[1].strip()) + if op == " < ": + return left < right + if op == " > ": + return left > right + if op == " <= ": + return left <= right + if op == " >= ": + return left >= right + if op == " <> " or op == " != ": + return left != right + if op == " = " or op == " == ": + return left == right + + # Handle arithmetic operators (lower precedence than comparison) + for op in [" + ", " - ", " * ", " / "]: + if op in formula: + parts = formula.split(op, 1) + left = await self._eval_simple(parts[0].strip()) + right = await self._eval_simple(parts[1].strip()) + # Treat None as 0 for arithmetic (PowerFx behavior) + if left is None: + left = 0 + if right is None: + right = 0 + # Coerce Decimal to float for arithmetic + if hasattr(left, "__float__"): + left = float(left) + if hasattr(right, "__float__"): + right = float(right) + if op == " + ": + return left + right + if op == " - ": + return left - right + if op == " * ": + return left * right + if op == " / ": + # Division by zero protection - return None (Blank in PowerFx) + if right == 0: + from agent_framework import get_logger + + logger = get_logger("agent_framework.declarative.workflows") + logger.warning(f"Division by zero in expression: {formula}") + return None + return left / right + + # Handle string literals + if (formula.startswith('"') and formula.endswith('"')) or (formula.startswith("'") and formula.endswith("'")): + return formula[1:-1] + + # Handle numeric literals + try: + if "." in formula: + return float(formula) + return int(formula) + except ValueError: + pass + + # Handle boolean literals + if formula.lower() == "true": + return True + if formula.lower() == "false": + return False + + # Handle variable references + if "." in formula: + path = formula + if formula.startswith("Local."): + path = "turn." + formula[6:] + elif formula.startswith("System."): + path = "system." + formula[7:] + elif formula.startswith("Workflow."): + path = "workflow." + formula[9:] + elif formula.startswith("inputs."): + path = "workflow.inputs." + formula[7:] + return await self.get(path) + + return formula + + def _parse_function_args(self, args_str: str) -> list[str]: + """Parse function arguments, handling nested parentheses.""" + args: list[str] = [] + current = "" + depth = 0 + in_string = False + string_char = None + + for char in args_str: + if char in ('"', "'") and not in_string: + in_string = True + string_char = char + current += char + elif char == string_char and in_string: + in_string = False + string_char = None + current += char + elif char == "(" and not in_string: + depth += 1 + current += char + elif char == ")" and not in_string: + depth -= 1 + current += char + elif char == "," and depth == 0 and not in_string: + args.append(current.strip()) + current = "" + else: + current += char + + if current.strip(): + args.append(current.strip()) + + return args + + async def eval_if_expression(self, value: Any) -> Any: + """Evaluate a value if it's a PowerFx expression, otherwise return as-is.""" + if isinstance(value, str): + return await self.eval(value) + if isinstance(value, dict): + value_dict: dict[str, Any] = dict(value) # type: ignore[arg-type] + return {k: await self.eval_if_expression(v) for k, v in value_dict.items()} + if isinstance(value, list): + value_list: list[Any] = list(value) # type: ignore[arg-type] + return [await self.eval_if_expression(item) for item in value_list] + return value + + async def interpolate_string(self, text: str) -> str: + """Interpolate {Variable.Path} references in a string. + + This handles template-style variable substitution like: + - "Created ticket #{Local.TicketParameters.TicketId}" + - "Routing to {Local.RoutingParameters.TeamName}" + + Args: + text: Text that may contain {Variable.Path} references + + Returns: + Text with variables interpolated + """ + import re + + async def replace_var(match: re.Match[str]) -> str: + var_path: str = match.group(1) + # Map .NET style to Python style (Local.X -> turn.X) + path = var_path + if var_path.startswith("Local."): + path = "turn." + var_path[6:] + elif var_path.startswith("System."): + path = "system." + var_path[7:] + value = await self.get(path) + return str(value) if value is not None else "" + + # Match {Variable.Path} patterns + pattern = r"\{([A-Za-z][A-Za-z0-9_.]*)\}" + + # re.sub doesn't support async, so we need to do it manually + result = text + for match in re.finditer(pattern, text): + replacement = await replace_var(match) + result = result.replace(match.group(0), replacement, 1) + + return result + + +# Message types for inter-executor communication +# These are defined before DeclarativeActionExecutor since it references them + + +class ActionTrigger: + """Message that triggers a declarative action executor. + + This is sent between executors in the graph to pass control + and any action-specific data. + """ + + def __init__(self, data: Any = None): + """Initialize the action trigger. + + Args: + data: Optional data to pass to the action + """ + self.data = data + + +class ActionComplete: + """Message sent when a declarative action completes. + + This is sent to downstream executors to continue the workflow. + """ + + def __init__(self, result: Any = None): + """Initialize the completion message. + + Args: + result: Optional result from the action + """ + self.result = result + + +@dataclass +class ConditionResult: + """Result of evaluating a condition (If/Switch). + + This message is output by ConditionEvaluatorExecutor and SwitchEvaluatorExecutor + to indicate which branch should be taken. + """ + + matched: bool + branch_index: int # Which branch matched (0 = first, -1 = else/default) + value: Any = None # The evaluated condition value + + +@dataclass +class LoopIterationResult: + """Result of a loop iteration step. + + This message is output by ForeachInitExecutor and ForeachNextExecutor + to indicate whether the loop should continue. + """ + + has_next: bool + current_item: Any = None + current_index: int = 0 + + +@dataclass +class LoopControl: + """Signal for loop control (break/continue). + + This message is output by BreakLoopExecutor and ContinueLoopExecutor. + """ + + action: Literal["break", "continue"] + + +# Union type for any declarative action message - allows executors to accept +# messages from triggers, completions, and control flow results +DeclarativeMessage = ActionTrigger | ActionComplete | ConditionResult | LoopIterationResult | LoopControl + + +class DeclarativeActionExecutor(Executor): + """Base class for declarative action executors. + + Each declarative action (SetValue, SendActivity, etc.) is implemented + as a subclass of this executor. The executor receives an ActionInput + message containing the action definition and state reference. + """ + + def __init__( + self, + action_def: dict[str, Any], + *, + id: str | None = None, + ): + """Initialize the declarative action executor. + + Args: + action_def: The action definition from YAML + id: Optional executor ID (defaults to action id or generated) + """ + action_id = id or action_def.get("id") or f"{action_def.get('kind', 'action')}_{hash(str(action_def)) % 10000}" + super().__init__(id=action_id, defer_discovery=True) + self._action_def = action_def + + # Manually register handlers after initialization + self._handlers = {} + self._handler_specs = [] + self._discover_handlers() + self._discover_response_handlers() + + @property + def action_def(self) -> dict[str, Any]: + """Get the action definition.""" + return self._action_def + + @property + def display_name(self) -> str | None: + """Get the display name for logging.""" + return self._action_def.get("displayName") + + def _get_state(self, shared_state: SharedState) -> DeclarativeWorkflowState: + """Get the declarative workflow state wrapper.""" + return DeclarativeWorkflowState(shared_state) + + async def _ensure_state_initialized( + self, + ctx: WorkflowContext[Any, Any], + trigger: Any, + ) -> DeclarativeWorkflowState: + """Ensure declarative state is initialized. + + Follows .NET's DefaultTransform pattern - accepts any input type: + - dict/Mapping: Used directly as workflow.inputs + - str: Converted to {"input": value} + - DeclarativeMessage: Internal message, no initialization needed + - Any other type: Converted via str() to {"input": str(value)} + + Args: + ctx: The workflow context + trigger: The trigger message - can be any type + + Returns: + The initialized DeclarativeWorkflowState + """ + state = self._get_state(ctx.shared_state) + + if isinstance(trigger, dict): + # Structured inputs - use directly + await state.initialize(trigger) # type: ignore + elif isinstance(trigger, str): + # String input - wrap in dict + await state.initialize({"input": trigger}) + elif not isinstance( + trigger, (ActionTrigger, ActionComplete, ConditionResult, LoopIterationResult, LoopControl) + ): + # Any other type - convert to string like .NET's DefaultTransform + await state.initialize({"input": str(trigger)}) + + return state diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_builder.py b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_builder.py new file mode 100644 index 0000000000..73d5c74b75 --- /dev/null +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_builder.py @@ -0,0 +1,973 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Builder that transforms declarative YAML into a workflow graph. + +This module provides the DeclarativeWorkflowBuilder which is analogous to +.NET's WorkflowActionVisitor + WorkflowElementWalker. It walks the YAML +action definitions and creates a proper workflow graph with: +- Executor nodes for each action +- Edges for sequential flow +- Condition evaluator executors for If/Switch that ensure first-match semantics +- Loop edges for foreach +""" + +from typing import Any + +from agent_framework._workflows import ( + Workflow, + WorkflowBuilder, +) + +from ._declarative_base import ( + ConditionResult, + DeclarativeActionExecutor, + LoopIterationResult, +) +from ._executors_agents import AGENT_ACTION_EXECUTORS, InvokeAzureAgentExecutor +from ._executors_basic import BASIC_ACTION_EXECUTORS +from ._executors_control_flow import ( + CONTROL_FLOW_EXECUTORS, + ELSE_BRANCH_INDEX, + ConditionGroupEvaluatorExecutor, + ForeachInitExecutor, + ForeachNextExecutor, + IfConditionEvaluatorExecutor, + JoinExecutor, + SwitchEvaluatorExecutor, +) +from ._executors_external_input import EXTERNAL_INPUT_EXECUTORS + +# Combined mapping of all action kinds to executor classes +ALL_ACTION_EXECUTORS = { + **BASIC_ACTION_EXECUTORS, + **CONTROL_FLOW_EXECUTORS, + **AGENT_ACTION_EXECUTORS, + **EXTERNAL_INPUT_EXECUTORS, +} + +# Action kinds that terminate control flow (no fall-through to successor) +# These actions transfer control elsewhere and should not have sequential edges to the next action +TERMINATOR_ACTIONS = frozenset({"Goto", "GotoAction", "BreakLoop", "ContinueLoop", "EndWorkflow", "EndDialog"}) + +# Required fields for specific action kinds (schema validation) +# Each action needs at least one of the listed fields (checked with alternates) +ACTION_REQUIRED_FIELDS: dict[str, list[str]] = { + "SetValue": ["path"], + "SetVariable": ["variable"], + "AppendValue": ["path", "value"], + "SendActivity": ["activity"], + "InvokeAzureAgent": ["agent"], + "Goto": ["target"], + "GotoAction": ["actionId"], + "Foreach": ["items", "actions"], + "If": ["condition"], + "Switch": ["value"], # Switch can use value/cases or conditions (ConditionGroup style) + "ConditionGroup": ["conditions"], + "RequestHumanInput": ["variable"], + "WaitForHumanInput": ["variable"], + "EmitEvent": ["event"], +} + +# Alternate field names that satisfy required field requirements +# Key: "ActionKind.field", Value: list of alternates that satisfy the requirement +ACTION_ALTERNATE_FIELDS: dict[str, list[str]] = { + "SetValue.path": ["variable"], + "Goto.target": ["actionId"], + "GotoAction.actionId": ["target"], + "InvokeAzureAgent.agent": ["agentName"], + "Foreach.items": ["itemsSource", "source"], # source is used in some schemas + "Switch.value": ["conditions"], # Switch can be condition-based instead of value-based +} + + +class DeclarativeWorkflowBuilder: + """Builds a Workflow graph from declarative YAML actions. + + This builder transforms declarative action definitions into a proper + workflow graph with executor nodes and edges. It handles: + - Sequential actions (simple edges) + - Conditional branching (If/Switch with condition edges) + - Loops (Foreach with loop edges) + - Jumps (Goto with target edges) + + Example usage: + yaml_def = { + "actions": [ + {"kind": "SendActivity", "activity": {"text": "Hello"}}, + {"kind": "SetValue", "path": "turn.count", "value": 0}, + ] + } + builder = DeclarativeWorkflowBuilder(yaml_def) + workflow = builder.build() + """ + + def __init__( + self, + yaml_definition: dict[str, Any], + workflow_id: str | None = None, + agents: dict[str, Any] | None = None, + checkpoint_storage: Any | None = None, + validate: bool = True, + ): + """Initialize the builder. + + Args: + yaml_definition: The parsed YAML workflow definition + workflow_id: Optional ID for the workflow (defaults to name from YAML) + agents: Registry of agent instances by name (for InvokeAzureAgent actions) + checkpoint_storage: Optional checkpoint storage for pause/resume support + validate: Whether to validate the workflow definition before building (default: True) + """ + self._yaml_def = yaml_definition + self._workflow_id = workflow_id or yaml_definition.get("name", "declarative_workflow") + self._executors: dict[str, Any] = {} # id -> executor + self._action_index = 0 # Counter for generating unique IDs + self._agents = agents or {} # Agent registry for agent executors + self._checkpoint_storage = checkpoint_storage + self._pending_gotos: list[tuple[Any, str]] = [] # (goto_executor, target_id) + self._validate = validate + self._seen_explicit_ids: set[str] = set() # Track explicit IDs for duplicate detection + + def build(self) -> Workflow: + """Build the workflow graph. + + Returns: + A Workflow instance with all executors wired together + + Raises: + ValueError: If no actions are defined (empty workflow), or validation fails + """ + builder = WorkflowBuilder(name=self._workflow_id) + + # Enable checkpointing if storage is provided + if self._checkpoint_storage: + builder.with_checkpointing(self._checkpoint_storage) + + actions = self._yaml_def.get("actions", []) + if not actions: + # Empty workflow - raise an error since we need at least one executor + raise ValueError("Cannot build workflow with no actions. At least one action is required.") + + # Validate workflow definition before building + if self._validate: + self._validate_workflow(actions) + + # First pass: create all executors + entry_executor = self._create_executors_for_actions(actions, builder) + + # Set the entry point + if entry_executor: + # Check if entry is a control flow structure (If/Switch) + if getattr(entry_executor, "_is_if_structure", False) or getattr( + entry_executor, "_is_switch_structure", False + ): + # Create an entry passthrough node and wire to the structure's branches + entry_node = JoinExecutor({"kind": "Entry"}, id="_workflow_entry") + self._executors[entry_node.id] = entry_node + builder.set_start_executor(entry_node) + # Use _add_sequential_edge which knows how to wire to structures + self._add_sequential_edge(builder, entry_node, entry_executor) + else: + builder.set_start_executor(entry_executor) + else: + raise ValueError("Failed to create any executors from actions.") + + # Resolve pending gotos (back-edges for loops, forward-edges for jumps) + self._resolve_pending_gotos(builder) + + return builder.build() + + def _validate_workflow(self, actions: list[dict[str, Any]]) -> None: + """Validate the workflow definition before building. + + Performs: + - Schema validation (required fields for action types) + - Duplicate explicit action ID detection + - Circular goto reference detection + + Args: + actions: List of action definitions to validate + + Raises: + ValueError: If validation fails + """ + seen_ids: set[str] = set() + goto_targets: list[tuple[str, str | None]] = [] # (target_id, source_id) + defined_ids: set[str] = set() + + # Collect all defined IDs and validate each action + self._validate_actions_recursive(actions, seen_ids, goto_targets, defined_ids) + + # Check for circular goto chains (A -> B -> A) + # Build a simple graph of goto targets + self._validate_no_circular_gotos(goto_targets, defined_ids) + + def _validate_actions_recursive( + self, + actions: list[dict[str, Any]], + seen_ids: set[str], + goto_targets: list[tuple[str, str | None]], + defined_ids: set[str], + ) -> None: + """Recursively validate actions and collect metadata. + + Args: + actions: List of action definitions + seen_ids: Set of seen explicit IDs (for duplicate detection) + goto_targets: List of (target_id, source_id) tuples for goto validation + defined_ids: Set of all defined action IDs + """ + for action_def in actions: + kind = action_def.get("kind", "") + + # Check for duplicate explicit IDs + explicit_id = action_def.get("id") + if explicit_id: + if explicit_id in seen_ids: + raise ValueError(f"Duplicate action ID '{explicit_id}'. Action IDs must be unique.") + seen_ids.add(explicit_id) + defined_ids.add(explicit_id) + + # Schema validation: check required fields + required_fields = ACTION_REQUIRED_FIELDS.get(kind, []) + for field in required_fields: + if field not in action_def and not self._has_alternate_field(action_def, kind, field): + raise ValueError(f"Action '{kind}' is missing required field '{field}'. Action: {action_def}") + + # Collect goto targets for circular reference detection + if kind in ("Goto", "GotoAction"): + target = action_def.get("target") or action_def.get("actionId") + if target: + goto_targets.append((target, explicit_id)) + + # Recursively validate nested actions + if kind == "If": + then_actions = action_def.get("then", action_def.get("actions", [])) + if then_actions: + self._validate_actions_recursive(then_actions, seen_ids, goto_targets, defined_ids) + else_actions = action_def.get("else", []) + if else_actions: + self._validate_actions_recursive(else_actions, seen_ids, goto_targets, defined_ids) + + elif kind in ("Switch", "ConditionGroup"): + cases = action_def.get("cases", action_def.get("conditions", [])) + for case in cases: + case_actions = case.get("actions", []) + if case_actions: + self._validate_actions_recursive(case_actions, seen_ids, goto_targets, defined_ids) + else_actions = action_def.get("elseActions", action_def.get("else", action_def.get("default", []))) + if else_actions: + self._validate_actions_recursive(else_actions, seen_ids, goto_targets, defined_ids) + + elif kind == "Foreach": + body_actions = action_def.get("actions", []) + if body_actions: + self._validate_actions_recursive(body_actions, seen_ids, goto_targets, defined_ids) + + def _has_alternate_field(self, action_def: dict[str, Any], kind: str, field: str) -> bool: + """Check if an action has an alternate field that satisfies the requirement. + + Some actions support multiple field names for the same purpose. + + Args: + action_def: The action definition + kind: The action kind + field: The required field name + + Returns: + True if an alternate field exists + """ + key = f"{kind}.{field}" + return any(alt in action_def for alt in ACTION_ALTERNATE_FIELDS.get(key, [])) + + def _validate_no_circular_gotos( + self, + goto_targets: list[tuple[str, str | None]], + defined_ids: set[str], + ) -> None: + """Validate that there are no problematic circular goto chains. + + Note: Some circular references are valid (e.g., loop-back patterns). + This checks for direct self-references only as a basic validation. + + Args: + goto_targets: List of (target_id, source_id) tuples + defined_ids: Set of defined action IDs + """ + for target_id, source_id in goto_targets: + # Check for direct self-reference + if source_id and target_id == source_id: + raise ValueError( + f"Action '{source_id}' has a direct self-referencing Goto, which would cause an infinite loop." + ) + + def _resolve_pending_gotos(self, builder: WorkflowBuilder) -> None: + """Resolve pending goto edges after all executors are created. + + Creates edges from goto executors to their target executors. + + Raises: + ValueError: If a goto target references an action ID that does not exist. + """ + for goto_executor, target_id in self._pending_gotos: + target_executor = self._executors.get(target_id) + if target_executor: + # Create edge from goto to target + builder.add_edge(source=goto_executor, target=target_executor) + else: + available_ids = list(self._executors.keys()) + raise ValueError(f"Goto target '{target_id}' not found. Available action IDs: {available_ids}") + + def _create_executors_for_actions( + self, + actions: list[dict[str, Any]], + builder: WorkflowBuilder, + parent_context: dict[str, Any] | None = None, + ) -> Any | None: + """Create executors for a list of actions and wire them together. + + Args: + actions: List of action definitions + builder: The workflow builder + parent_context: Context from parent (e.g., loop info) + + Returns: + The first executor in the chain, or None if no actions + """ + if not actions: + return None + + first_executor = None + prev_executor = None + executors_in_chain: list[Any] = [] + + for action_def in actions: + executor = self._create_executor_for_action(action_def, builder, parent_context) + + if executor is None: + continue + + executors_in_chain.append(executor) + + if first_executor is None: + first_executor = executor + + # Wire sequential edge from previous executor + if prev_executor is not None: + self._add_sequential_edge(builder, prev_executor, executor) + + # Check if this action is a terminator (transfers control elsewhere) + # Terminators should not have fall-through edges to subsequent actions + action_kind = action_def.get("kind", "") + # Don't wire terminators to the next action - control flow ends there + prev_executor = None if action_kind in TERMINATOR_ACTIONS else executor + + # Store the chain for later reference + if first_executor is not None: + first_executor._chain_executors = executors_in_chain # type: ignore[attr-defined] + + return first_executor + + def _create_executor_for_action( + self, + action_def: dict[str, Any], + builder: WorkflowBuilder, + parent_context: dict[str, Any] | None = None, + ) -> Any | None: + """Create an executor for a single action. + + Args: + action_def: The action definition from YAML + builder: The workflow builder + parent_context: Context from parent + + Returns: + The created executor, or None if action type not supported + """ + kind = action_def.get("kind", "") + + # Handle special control flow actions + if kind == "If": + return self._create_if_structure(action_def, builder, parent_context) + if kind == "Switch" or kind == "ConditionGroup": + return self._create_switch_structure(action_def, builder, parent_context) + if kind == "Foreach": + return self._create_foreach_structure(action_def, builder, parent_context) + if kind == "Goto" or kind == "GotoAction": + return self._create_goto_reference(action_def, builder, parent_context) + if kind == "BreakLoop": + return self._create_break_executor(action_def, builder, parent_context) + if kind == "ContinueLoop": + return self._create_continue_executor(action_def, builder, parent_context) + + # Get the executor class for this action kind + executor_class = ALL_ACTION_EXECUTORS.get(kind) + + if executor_class is None: + # Unknown action type - skip with warning + # In production, might want to log this + return None + + # Create the executor with ID + # Priority: explicit ID from YAML > index-based ID (matches .NET behavior) + explicit_id = action_def.get("id") + if explicit_id: + action_id = explicit_id + else: + parent_id = (parent_context or {}).get("parent_id") + action_id = f"{parent_id}_{kind}_{self._action_index}" if parent_id else f"{kind}_{self._action_index}" + self._action_index += 1 + + # Pass agents to agent-related executors + executor: Any + if kind in ("InvokeAzureAgent",): + executor = InvokeAzureAgentExecutor(action_def, id=action_id, agents=self._agents) + else: + executor = executor_class(action_def, id=action_id) + self._executors[action_id] = executor + + return executor + + def _create_if_structure( + self, + action_def: dict[str, Any], + builder: WorkflowBuilder, + parent_context: dict[str, Any] | None = None, + ) -> Any: + """Create the graph structure for an If action. + + An If action is implemented with a condition evaluator executor that + outputs a ConditionResult. Edge conditions check the branch_index to + route to either the then or else branch. This ensures first-match + semantics (only one branch executes). + + Args: + action_def: The If action definition + builder: The workflow builder + parent_context: Context from parent + + Returns: + A structure representing the If with evaluator, branch entries and exits + """ + action_id = action_def.get("id") or f"If_{self._action_index}" + self._action_index += 1 + + condition_expr = action_def.get("condition", "true") + # Normalize boolean conditions from YAML to PowerFx-style strings + if condition_expr is True: + condition_expr = "=true" + elif condition_expr is False: + condition_expr = "=false" + elif isinstance(condition_expr, str) and not condition_expr.startswith("="): + # Bare string conditions should be evaluated as expressions + condition_expr = f"={condition_expr}" + + # Pass the If's ID as context for child action naming + branch_context = { + **(parent_context or {}), + "parent_id": action_id, + } + + # Create the condition evaluator executor + evaluator = IfConditionEvaluatorExecutor( + action_def, + condition_expr, + id=f"{action_id}_eval", + ) + self._executors[evaluator.id] = evaluator + + # Create then branch + then_actions = action_def.get("then", action_def.get("actions", [])) + then_entry = self._create_executors_for_actions(then_actions, builder, branch_context) + + # Create else branch + else_actions = action_def.get("else", []) + else_entry = self._create_executors_for_actions(else_actions, builder, branch_context) if else_actions else None + else_passthrough = None + if not else_entry: + # No else branch - create a passthrough for continuation when condition is false + else_passthrough = JoinExecutor({"kind": "ElsePassthrough"}, id=f"{action_id}_else_pass") + self._executors[else_passthrough.id] = else_passthrough + + # Wire evaluator to branches with conditions that check ConditionResult.branch_index + # branch_index=0 means "then" branch, branch_index=-1 (ELSE_BRANCH_INDEX) means "else" + # For nested If/Switch structures, wire to the evaluator (entry point) + if then_entry: + then_target = self._get_structure_entry(then_entry) + builder.add_edge( + source=evaluator, + target=then_target, + condition=lambda msg, _: isinstance(msg, ConditionResult) and msg.branch_index == 0, + ) + if else_entry: + else_target = self._get_structure_entry(else_entry) + builder.add_edge( + source=evaluator, + target=else_target, + condition=lambda msg, _: isinstance(msg, ConditionResult) and msg.branch_index == ELSE_BRANCH_INDEX, + ) + elif else_passthrough: + builder.add_edge( + source=evaluator, + target=else_passthrough, + condition=lambda msg, _: isinstance(msg, ConditionResult) and msg.branch_index == ELSE_BRANCH_INDEX, + ) + + # Get branch exit executors for later wiring to successor + then_exit = self._get_branch_exit(then_entry) + else_exit = self._get_branch_exit(else_entry) if else_entry else else_passthrough + + # Collect all branch exits (for wiring to successor) + branch_exits: list[Any] = [] + if then_exit: + branch_exits.append(then_exit) + if else_exit: + branch_exits.append(else_exit) + + # Create an IfStructure to hold all the info needed for wiring + class IfStructure: + def __init__(self) -> None: + self.id = action_id + self.evaluator = evaluator # The entry point for this structure + self.then_entry = then_entry + self.else_entry = else_entry + self.else_passthrough = else_passthrough + self.branch_exits = branch_exits # All exits that need wiring to successor + self._is_if_structure = True + + return IfStructure() + + def _create_switch_structure( + self, + action_def: dict[str, Any], + builder: WorkflowBuilder, + parent_context: dict[str, Any] | None = None, + ) -> Any: + """Create the graph structure for a Switch/ConditionGroup action. + + Supports two schema formats: + 1. ConditionGroup schema (matches .NET): + - conditions: list of {condition: expr, actions: [...]} + - elseActions: default actions + + 2. Switch schema (interpreter style): + - value: expression to match + - cases: list of {match: value, actions: [...]} + - default: default actions + + Both use evaluator executors that output ConditionResult with branch_index + for first-match semantics. + + Args: + action_def: The Switch/ConditionGroup action definition + builder: The workflow builder + parent_context: Context from parent + + Returns: + A SwitchStructure containing branch info for wiring + """ + action_id = action_def.get("id") or f"Switch_{self._action_index}" + self._action_index += 1 + + # Pass the Switch's ID as context for child action naming + branch_context = { + **(parent_context or {}), + "parent_id": action_id, + } + + # Detect schema type: + # - If "cases" present: interpreter Switch schema (value/cases/default) + # - If "conditions" present: ConditionGroup schema (conditions/elseActions) + cases = action_def.get("cases", []) + conditions = action_def.get("conditions", []) + + if cases: + # Interpreter Switch schema: value/cases/default + evaluator: DeclarativeActionExecutor = SwitchEvaluatorExecutor( + action_def, + cases, + id=f"{action_id}_eval", + ) + branch_items = cases + else: + # ConditionGroup schema: conditions/elseActions + evaluator = ConditionGroupEvaluatorExecutor( + action_def, + conditions, + id=f"{action_id}_eval", + ) + branch_items = conditions + + self._executors[evaluator.id] = evaluator + + # Collect branches and create executors for each + branch_entries: list[tuple[int, Any]] = [] # (branch_index, entry_executor) + branch_exits: list[Any] = [] # All exits that need wiring to successor + + for i, item in enumerate(branch_items): + branch_actions = item.get("actions", []) + # Use branch-specific context + case_context = {**branch_context, "parent_id": f"{action_id}_case{i}"} + branch_entry = self._create_executors_for_actions(branch_actions, builder, case_context) + + if branch_entry: + branch_entries.append((i, branch_entry)) + # Track exit for later wiring + branch_exit = self._get_branch_exit(branch_entry) + if branch_exit: + branch_exits.append(branch_exit) + + # Handle else/default branch + # .NET uses "elseActions", interpreter uses "else" or "default" + else_actions = action_def.get("elseActions", action_def.get("else", action_def.get("default", []))) + default_entry = None + default_passthrough = None + if else_actions: + default_context = {**branch_context, "parent_id": f"{action_id}_else"} + default_entry = self._create_executors_for_actions(else_actions, builder, default_context) + if default_entry: + default_exit = self._get_branch_exit(default_entry) + if default_exit: + branch_exits.append(default_exit) + else: + # No else actions - create a passthrough for the "no match" case + # This allows the workflow to continue to the next action when no condition matches + default_passthrough = JoinExecutor({"kind": "DefaultPassthrough"}, id=f"{action_id}_default") + self._executors[default_passthrough.id] = default_passthrough + branch_exits.append(default_passthrough) + + # Wire evaluator to branches with conditions that check ConditionResult.branch_index + # For nested If/Switch structures, wire to the evaluator (entry point) + for branch_index, branch_entry in branch_entries: + # Capture branch_index in closure properly using a factory function for type inference + def make_branch_condition(expected: int) -> Any: + return lambda msg, _: isinstance(msg, ConditionResult) and msg.branch_index == expected # type: ignore + + branch_target = self._get_structure_entry(branch_entry) + builder.add_edge( + source=evaluator, + target=branch_target, + condition=make_branch_condition(branch_index), + ) + + # Wire evaluator to default/else branch + if default_entry: + default_target = self._get_structure_entry(default_entry) + builder.add_edge( + source=evaluator, + target=default_target, + condition=lambda msg, _: isinstance(msg, ConditionResult) and msg.branch_index == ELSE_BRANCH_INDEX, + ) + elif default_passthrough: + builder.add_edge( + source=evaluator, + target=default_passthrough, + condition=lambda msg, _: isinstance(msg, ConditionResult) and msg.branch_index == ELSE_BRANCH_INDEX, + ) + + # Create a SwitchStructure to hold all the info needed for wiring + class SwitchStructure: + def __init__(self) -> None: + self.id = action_id + self.evaluator = evaluator # The entry point for this structure + self.branch_entries = branch_entries + self.default_entry = default_entry + self.default_passthrough = default_passthrough + self.branch_exits = branch_exits # All exits that need wiring to successor + self._is_switch_structure = True + + return SwitchStructure() + + def _create_foreach_structure( + self, + action_def: dict[str, Any], + builder: WorkflowBuilder, + parent_context: dict[str, Any] | None = None, + ) -> Any: + """Create the graph structure for a Foreach action. + + A Foreach action becomes: + 1. ForeachInit node that initializes the loop + 2. Loop body actions + 3. ForeachNext node that advances to next item + 4. Back-edge from ForeachNext to loop body (when has_next=True) + 5. Exit edge from ForeachNext (when has_next=False) + + Args: + action_def: The Foreach action definition + builder: The workflow builder + parent_context: Context from parent + + Returns: + The foreach init executor (entry point) + """ + action_id = action_def.get("id") or f"Foreach_{self._action_index}" + self._action_index += 1 + + # Create foreach init executor + init_executor = ForeachInitExecutor(action_def, id=f"{action_id}_init") + self._executors[init_executor.id] = init_executor + + # Create foreach next executor (for advancing to next item) + next_executor = ForeachNextExecutor(action_def, init_executor.id, id=f"{action_id}_next") + self._executors[next_executor.id] = next_executor + + # Create join node for loop exit + join_executor = JoinExecutor({"kind": "Join"}, id=f"{action_id}_exit") + self._executors[join_executor.id] = join_executor + + # Create loop body + body_actions = action_def.get("actions", []) + loop_context = { + **(parent_context or {}), + "loop_id": action_id, + "loop_next_executor": next_executor, + } + body_entry = self._create_executors_for_actions(body_actions, builder, loop_context) + + if body_entry: + # For nested If/Switch structures, wire to the evaluator (entry point) + body_target = self._get_structure_entry(body_entry) + + # Init -> body (when has_next=True) + builder.add_edge( + source=init_executor, + target=body_target, + condition=lambda msg, _: isinstance(msg, LoopIterationResult) and msg.has_next, + ) + + # Body exit -> Next (get all exits from body and wire to next_executor) + body_exits = self._get_source_exits(body_entry) + for body_exit in body_exits: + builder.add_edge(source=body_exit, target=next_executor) + + # Next -> body (when has_next=True, loop back) + builder.add_edge( + source=next_executor, + target=body_target, + condition=lambda msg, _: isinstance(msg, LoopIterationResult) and msg.has_next, + ) + + # Init -> join (when has_next=False, empty collection) + builder.add_edge( + source=init_executor, + target=join_executor, + condition=lambda msg, _: isinstance(msg, LoopIterationResult) and not msg.has_next, + ) + + # Next -> join (when has_next=False, loop complete) + builder.add_edge( + source=next_executor, + target=join_executor, + condition=lambda msg, _: isinstance(msg, LoopIterationResult) and not msg.has_next, + ) + + init_executor._exit_executor = join_executor # type: ignore[attr-defined] + return init_executor + + def _create_goto_reference( + self, + action_def: dict[str, Any], + builder: WorkflowBuilder, + parent_context: dict[str, Any] | None = None, + ) -> Any | None: + """Create a GotoAction executor that jumps to the target action. + + GotoAction creates a back-edge (or forward-edge) in the graph to the target action. + We create a pass-through executor and record the pending edge to be resolved + after all executors are created. + """ + from ._executors_control_flow import JoinExecutor + + target_id = action_def.get("target") or action_def.get("actionId") + + if not target_id: + return None + + # Create a pass-through executor for the goto + action_id = action_def.get("id") or f"goto_{target_id}_{self._action_index}" + self._action_index += 1 + + # Use JoinExecutor as a simple pass-through node + goto_executor = JoinExecutor(action_def, id=action_id) + self._executors[action_id] = goto_executor + + # Record pending goto edge to be resolved after all executors created + self._pending_gotos.append((goto_executor, target_id)) + + return goto_executor + + def _create_break_executor( + self, + action_def: dict[str, Any], + builder: WorkflowBuilder, + parent_context: dict[str, Any] | None = None, + ) -> Any | None: + """Create a break executor for loop control. + + Raises: + ValueError: If BreakLoop is used outside of a loop. + """ + from ._executors_control_flow import BreakLoopExecutor + + if parent_context and "loop_next_executor" in parent_context: + loop_next = parent_context["loop_next_executor"] + action_id = action_def.get("id") or f"Break_{self._action_index}" + self._action_index += 1 + + executor = BreakLoopExecutor(action_def, loop_next.id, id=action_id) + self._executors[action_id] = executor + + # Wire break to loop next + builder.add_edge(source=executor, target=loop_next) + + return executor + + raise ValueError("BreakLoop action can only be used inside a Foreach loop") + + def _create_continue_executor( + self, + action_def: dict[str, Any], + builder: WorkflowBuilder, + parent_context: dict[str, Any] | None = None, + ) -> Any | None: + """Create a continue executor for loop control. + + Raises: + ValueError: If ContinueLoop is used outside of a loop. + """ + from ._executors_control_flow import ContinueLoopExecutor + + if parent_context and "loop_next_executor" in parent_context: + loop_next = parent_context["loop_next_executor"] + action_id = action_def.get("id") or f"Continue_{self._action_index}" + self._action_index += 1 + + executor = ContinueLoopExecutor(action_def, loop_next.id, id=action_id) + self._executors[action_id] = executor + + # Wire continue to loop next + builder.add_edge(source=executor, target=loop_next) + + return executor + + raise ValueError("ContinueLoop action can only be used inside a Foreach loop") + + def _add_sequential_edge( + self, + builder: WorkflowBuilder, + source: Any, + target: Any, + ) -> None: + """Add a sequential edge between two executors. + + Handles control flow structures: + - If source is a structure (If/Switch), wire from all branch exits + - If target is a structure (If/Switch), wire with conditional edges to branches + """ + # Get all source exit points + source_exits = self._get_source_exits(source) + + # Wire each source exit to target + for source_exit in source_exits: + self._wire_to_target(builder, source_exit, target) + + def _get_source_exits(self, source: Any) -> list[Any]: + """Get all exit executors from a source (handles structures with multiple exits).""" + # Check if source is a structure with branch_exits + if hasattr(source, "branch_exits"): + # Collect all exits, recursively flattening nested structures + all_exits: list[Any] = [] + for exit_item in source.branch_exits: + if hasattr(exit_item, "branch_exits"): + # Nested structure - recurse + all_exits.extend(self._collect_all_exits(exit_item)) + else: + all_exits.append(exit_item) + return all_exits if all_exits else [] + + # Check if source has a single exit executor + actual_exit = getattr(source, "_exit_executor", source) + return [actual_exit] + + def _wire_to_target( + self, + builder: WorkflowBuilder, + source: Any, + target: Any, + ) -> None: + """Wire a single source executor to a target (which may be a structure). + + For If/Switch structures, wire to the evaluator executor. The evaluator + handles condition evaluation and outputs ConditionResult, which is then + routed to the appropriate branch by edges created in _create_*_structure. + """ + # Check if target is an IfStructure or SwitchStructure (wire to evaluator) + if getattr(target, "_is_if_structure", False) or getattr(target, "_is_switch_structure", False): + # Wire from source to the evaluator - the evaluator then routes to branches + builder.add_edge(source=source, target=target.evaluator) + + else: + # Normal sequential edge to a regular executor + builder.add_edge(source=source, target=target) + + def _get_structure_entry(self, entry: Any) -> Any: + """Get the entry point executor for a structure or regular executor. + + For If/Switch structures, returns the evaluator. For regular executors, + returns the executor itself. + + Args: + entry: An executor or structure + + Returns: + The entry point executor + """ + is_structure = getattr(entry, "_is_if_structure", False) or getattr(entry, "_is_switch_structure", False) + return entry.evaluator if is_structure else entry + + def _get_branch_exit(self, branch_entry: Any) -> Any | None: + """Get the exit executor of a branch. + + For a linear sequence of actions, returns the last executor. + For nested structures, returns None (they have their own branch_exits). + + Args: + branch_entry: The first executor of the branch + + Returns: + The exit executor, or None if branch is empty or ends with a structure + """ + if branch_entry is None: + return None + + # Get the chain of executors in this branch + chain = getattr(branch_entry, "_chain_executors", [branch_entry]) + + last_executor = chain[-1] + + # Check if last executor is a structure with branch_exits + # In that case, we return the structure so its exits can be collected + if hasattr(last_executor, "branch_exits"): + return last_executor + + # Regular executor - get its exit point + return getattr(last_executor, "_exit_executor", last_executor) + + def _collect_all_exits(self, structure: Any) -> list[Any]: + """Recursively collect all exit executors from a structure.""" + exits: list[Any] = [] + + if not hasattr(structure, "branch_exits"): + # Not a structure - return the executor itself + actual_exit = getattr(structure, "_exit_executor", structure) + return [actual_exit] + + for exit_item in structure.branch_exits: + if hasattr(exit_item, "branch_exits"): + # Nested structure - recurse + exits.extend(self._collect_all_exits(exit_item)) + else: + exits.append(exit_item) + + return exits diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py new file mode 100644 index 0000000000..84ba5d79b4 --- /dev/null +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py @@ -0,0 +1,847 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Agent invocation executors for declarative workflows. + +These executors handle invoking Azure AI Foundry agents and other AI agents, +supporting both streaming responses and human-in-loop patterns. + +Aligned with .NET's InvokeAzureAgentExecutor behavior including: +- Structured input with arguments and messages +- External loop support for human-in-loop patterns +- Output with messages and responseObject (JSON parsing) +- AutoSend behavior control +""" + +import contextlib +import json +import logging +import uuid +from dataclasses import dataclass, field +from typing import Any, cast + +from agent_framework import ChatMessage +from agent_framework._types import FunctionCallContent, FunctionResultContent +from agent_framework._workflows import ( + WorkflowContext, + handler, + response_handler, +) + +from ._declarative_base import ( + ActionComplete, + DeclarativeActionExecutor, + DeclarativeWorkflowState, +) + +logger = logging.getLogger(__name__) + +# Keys for agent-related state +AGENT_REGISTRY_KEY = "_agent_registry" +TOOL_REGISTRY_KEY = "_tool_registry" +# Key to store external loop state for resumption +EXTERNAL_LOOP_STATE_KEY = "_external_loop_state" + + +class AgentInvocationError(Exception): + """Raised when an agent invocation fails. + + Attributes: + agent_name: Name of the agent that failed + message: Error description + """ + + def __init__(self, agent_name: str, message: str) -> None: + self.agent_name = agent_name + super().__init__(f"Agent '{agent_name}' invocation failed: {message}") + + +@dataclass +class AgentResult: + """Result from an agent invocation.""" + + success: bool + response: str + agent_name: str + messages: list[ChatMessage] = field(default_factory=lambda: cast(list[ChatMessage], [])) + tool_calls: list[FunctionCallContent] = field(default_factory=lambda: cast(list[FunctionCallContent], [])) + error: str | None = None + + +@dataclass +class ExternalInputRequest: + """Request for external input during agent invocation. + + Emitted when externalLoop.when condition evaluates to true, + signaling that the workflow should yield and wait for user input. + + This is the request type used with ctx.request_info() to implement + the Yield/Resume pattern for human-in-loop workflows. + + Examples: + .. code-block:: python + + from agent_framework import run_context + from agent_framework_declarative import ( + ExternalInputRequest, + ExternalInputResponse, + WorkflowFactory, + ) + + factory = WorkflowFactory() + workflow = factory.create_workflow_from_yaml_path("hitl_workflow.yaml") + + + async def run_with_hitl(): + # Set up external input handler + async def on_request(request: ExternalInputRequest) -> ExternalInputResponse: + print(f"Agent '{request.agent_name}' needs input:") + print(f" Response: {request.agent_response}") + user_input = input("Your response: ") + return ExternalInputResponse(user_input=user_input) + + async with run_context(request_handler=on_request) as ctx: + async for event in workflow.run_stream(ctx=ctx): + print(event) + """ + + request_id: str + agent_name: str + agent_response: str + iteration: int = 0 + messages: list[ChatMessage] = field(default_factory=lambda: cast(list[ChatMessage], [])) + function_calls: list[FunctionCallContent] = field(default_factory=lambda: cast(list[FunctionCallContent], [])) + + +@dataclass +class ExternalInputResponse: + """Response to an ExternalInputRequest. + + Provided by the caller to resume agent execution with new user input. + This is the response type expected by the response_handler. + + Examples: + .. code-block:: python + + from agent_framework_declarative import ExternalInputResponse + + # Basic response with user text input + response = ExternalInputResponse(user_input="Yes, please proceed with the order.") + + .. code-block:: python + + from agent_framework_declarative import ExternalInputResponse + + # Response with additional message history + response = ExternalInputResponse( + user_input="Approved", + messages=[], # Additional context messages if needed + ) + """ + + user_input: str + messages: list[ChatMessage] = field(default_factory=lambda: cast(list[ChatMessage], [])) + function_results: dict[str, FunctionResultContent] = field( + default_factory=lambda: cast(dict[str, FunctionResultContent], {}) + ) + + +@dataclass +class ExternalLoopState: + """State saved for external loop resumption. + + Stored in shared_state to allow the response_handler to + continue the loop with the same configuration. + """ + + agent_name: str + iteration: int + external_loop_when: str + messages_var: str | None + response_obj_var: str | None + result_property: str | None + auto_send: bool + messages_path: str = "conversation.messages" + max_iterations: int = 100 + + +def _map_variable_to_path(variable: str) -> str: + """Map .NET-style variable names to state paths. + + Args: + variable: Variable name like 'Local.X' or 'System.ConversationId' + + Returns: + State path like 'turn.X' or 'system.ConversationId' + """ + if variable.startswith("Local."): + return "turn." + variable[6:] + if variable.startswith("System."): + return "system." + variable[7:] + if variable.startswith("Workflow."): + return "workflow." + variable[9:] + if "." in variable: + return variable + return "turn." + variable + + +class InvokeAzureAgentExecutor(DeclarativeActionExecutor): + """Executor that invokes an Azure AI Foundry agent. + + This executor supports both Python-style and .NET-style YAML schemas: + + Python-style (simple): + kind: InvokeAzureAgent + agent: MenuAgent + input: =turn.userInput + resultProperty: turn.agentResponse + + .NET-style (full featured): + kind: InvokeAzureAgent + agent: + name: AgentName + conversationId: =System.ConversationId + input: + arguments: + param1: =turn.value1 + param2: literal value + messages: =conversation.messages + externalLoop: + when: =turn.needsMoreInput + output: + messages: Local.ResponseMessages + responseObject: Local.StructuredResponse + autoSend: true + + Features: + - Structured input with arguments and messages + - External loop support for human-in-loop patterns + - Output with messages and responseObject (JSON parsing) + - AutoSend behavior control for streaming output + """ + + def __init__( + self, + action_def: dict[str, Any], + *, + id: str | None = None, + agents: dict[str, Any] | None = None, + ): + """Initialize the agent executor. + + Args: + action_def: The action definition from YAML + id: Optional executor ID + agents: Registry of agent instances by name + """ + super().__init__(action_def, id=id) + self._agents = agents or {} + + def _get_agent_name(self, state: Any) -> str | None: + """Extract agent name from action definition. + + Supports both simple string and nested object formats. + """ + agent_config = self._action_def.get("agent") + + if isinstance(agent_config, str): + return agent_config + + if isinstance(agent_config, dict): + agent_dict = cast(dict[str, Any], agent_config) + name = agent_dict.get("name") + if name is not None and isinstance(name, str): + # Support dynamic agent name from expression (would need async eval) + return str(name) + + agent_name = self._action_def.get("agentName") + return str(agent_name) if isinstance(agent_name, str) else None + + def _get_input_config(self) -> tuple[dict[str, Any], Any, str | None, int]: + """Parse input configuration. + + Returns: + Tuple of (arguments dict, messages expression, externalLoop.when expression, maxIterations) + """ + input_config = self._action_def.get("input", {}) + + if not isinstance(input_config, dict): + # Simple input - treat as message directly + return {}, input_config, None, 100 + + input_dict = cast(dict[str, Any], input_config) + arguments: dict[str, Any] = cast(dict[str, Any], input_dict.get("arguments", {})) + messages: Any = input_dict.get("messages") + + # Extract external loop configuration + external_loop_when: str | None = None + max_iterations: int = 100 # Default safety limit + external_loop = input_dict.get("externalLoop") + if isinstance(external_loop, dict): + loop_dict = cast(dict[str, Any], external_loop) + when_val = loop_dict.get("when") + external_loop_when = str(when_val) if when_val is not None else None + max_iter_val = loop_dict.get("maxIterations") + if max_iter_val is not None: + max_iterations = int(max_iter_val) + + return arguments, messages, external_loop_when, max_iterations + + def _get_output_config(self) -> tuple[str | None, str | None, str | None, bool]: + """Parse output configuration. + + Returns: + Tuple of (messages var, responseObject var, resultProperty, autoSend) + """ + output_config = self._action_def.get("output", {}) + + # Legacy Python-style + result_property: str | None = cast(str | None, self._action_def.get("resultProperty")) + + if not isinstance(output_config, dict): + return None, None, result_property, True + + output_dict = cast(dict[str, Any], output_config) + messages_var_val: Any = output_dict.get("messages") + messages_var: str | None = str(messages_var_val) if messages_var_val is not None else None + response_obj_val: Any = output_dict.get("responseObject") + response_obj_var: str | None = str(response_obj_val) if response_obj_val is not None else None + property_val: Any = output_dict.get("property") + property_var: str | None = str(property_val) if property_val is not None else None + auto_send_val: Any = output_dict.get("autoSend", True) + auto_send: bool = bool(auto_send_val) + + return messages_var, response_obj_var, property_var or result_property, auto_send + + def _get_conversation_id(self) -> str | None: + """Get the conversation ID expression from action definition. + + Returns: + The conversationId expression/value, or None if not specified + """ + return self._action_def.get("conversationId") + + async def _get_conversation_messages_path( + self, state: DeclarativeWorkflowState, conversation_id_expr: str | None + ) -> str: + """Get the state path for conversation messages. + + Args: + state: Workflow state for expression evaluation + conversation_id_expr: The conversationId expression from action definition + + Returns: + State path for messages (e.g., "conversation.messages" or "system.conversations.{id}.messages") + """ + if not conversation_id_expr: + return "conversation.messages" + + # Evaluate the conversation ID expression + evaluated_id = await state.eval_if_expression(conversation_id_expr) + if not evaluated_id: + return "conversation.messages" + + # Use conversation-specific messages path + return f"system.conversations.{evaluated_id}.messages" + + async def _build_input_text(self, state: Any, arguments: dict[str, Any], messages_expr: Any) -> str: + """Build input text from arguments and messages. + + Args: + state: Workflow state for expression evaluation + arguments: Input arguments to evaluate + messages_expr: Messages expression or direct input + + Returns: + Input text for the agent + """ + # Evaluate arguments + evaluated_args: dict[str, Any] = {} + for key, value in arguments.items(): + evaluated_args[key] = await state.eval_if_expression(value) + + # Evaluate messages/input + if messages_expr: + evaluated_input: Any = await state.eval_if_expression(messages_expr) + if isinstance(evaluated_input, str): + return evaluated_input + if isinstance(evaluated_input, list) and evaluated_input: + # Extract text from last message + last: Any = evaluated_input[-1] + if isinstance(last, str): + return last + if isinstance(last, dict): + last_dict = cast(dict[str, Any], last) + content_val: Any = last_dict.get("content", last_dict.get("text", "")) + return str(content_val) if content_val else "" + if last is not None and hasattr(last, "text"): + return str(getattr(last, "text", "")) + if evaluated_input: + return str(cast(Any, evaluated_input)) + return "" + + # Fallback chain for implicit input (like .NET conversationId pattern): + # 1. turn.input / turn.userInput (explicit turn state) + # 2. system.LastMessage.Text (previous agent's response) + # 3. workflow.inputs (first agent gets workflow inputs) + input_text: str = str(await state.get("turn.input") or await state.get("turn.userInput") or "") + if not input_text: + # Try system.LastMessage.Text (used by external loop and agent chaining) + last_message: Any = await state.get("system.LastMessage") + if isinstance(last_message, dict): + last_msg_dict = cast(dict[str, Any], last_message) + text_val: Any = last_msg_dict.get("Text", "") + input_text = str(text_val) if text_val else "" + if not input_text: + # Fall back to workflow inputs (for first agent in chain) + inputs: Any = await state.get("workflow.inputs") + if isinstance(inputs, dict): + inputs_dict = cast(dict[str, Any], inputs) + # If single input, use its value directly + if len(inputs_dict) == 1: + input_text = str(next(iter(inputs_dict.values()))) + else: + # Multiple inputs - format as key: value pairs + input_text = "\n".join(f"{k}: {v}" for k, v in inputs_dict.items()) + return input_text if input_text else "" + + def _get_agent(self, agent_name: str, ctx: WorkflowContext[Any, Any]) -> Any: + """Get agent from registry (sync helper for response handler).""" + return self._agents.get(agent_name) if self._agents else None + + async def _invoke_agent_and_store_results( + self, + agent: Any, + agent_name: str, + input_text: str, + state: DeclarativeWorkflowState, + ctx: WorkflowContext[ActionComplete, str], + messages_var: str | None, + response_obj_var: str | None, + result_property: str | None, + auto_send: bool, + messages_path: str = "conversation.messages", + ) -> tuple[str, list[Any], list[Any]]: + """Invoke agent and store results in state. + + Args: + agent: The agent instance to invoke + agent_name: Name of the agent for logging + input_text: User input text + state: Workflow state + ctx: Workflow context + messages_var: Output variable for messages + response_obj_var: Output variable for parsed response object + result_property: Output property for result + auto_send: Whether to auto-send output to context + messages_path: State path for conversation messages (default: "conversation.messages") + + Returns: + Tuple of (accumulated_response, all_messages, tool_calls) + """ + accumulated_response = "" + all_messages: list[ChatMessage] = [] + tool_calls: list[FunctionCallContent] = [] + + # Add user input to conversation history first (via state.append only) + if input_text: + user_message = ChatMessage(role="user", text=input_text) + await state.append(messages_path, user_message) + + # Get conversation history from state AFTER adding user message + # Note: We get a fresh copy to avoid mutation issues + conversation_history: list[ChatMessage] = await state.get(messages_path) or [] + + # Build messages list for agent (use history if available, otherwise just input) + messages_for_agent: list[ChatMessage] | str = conversation_history if conversation_history else input_text + + # 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 + if hasattr(agent, "run"): + result: Any = await agent.run(messages_for_agent) + if hasattr(result, "text") and result.text: + accumulated_response = str(result.text) + if auto_send: + await ctx.yield_output(str(result.text)) + elif isinstance(result, str): + accumulated_response = result + if auto_send: + await ctx.yield_output(result) + + if not isinstance(result, str): + result_messages: Any = getattr(result, "messages", None) + if result_messages is not None: + all_messages = list(cast(list[ChatMessage], result_messages)) + result_tool_calls: Any = getattr(result, "tool_calls", None) + if result_tool_calls is not None: + tool_calls = list(cast(list[FunctionCallContent], result_tool_calls)) + + else: + raise RuntimeError(f"Agent '{agent_name}' has no run or run_stream method") + + # Add messages to conversation history + # We need to include ALL messages from the agent run (including tool calls and tool results) + # to maintain proper conversation state for the next agent invocation + if all_messages: + # Agent returned full message history - use it + for msg in all_messages: + await state.append(messages_path, msg) + elif accumulated_response: + # No messages returned, create a simple assistant message + assistant_message = ChatMessage(role="assistant", text=accumulated_response) + await state.append(messages_path, assistant_message) + + # Store results in state - support both schema formats: + # - Graph mode: agent.response, agent.name + # - Interpreter mode: agent.text, agent.messages, agent.toolCalls + await state.set("agent.response", accumulated_response) + await state.set("agent.name", agent_name) + await state.set("agent.text", accumulated_response) + await state.set("agent.messages", all_messages if all_messages else []) + await state.set("agent.toolCalls", tool_calls if tool_calls else []) + + # Store System.LastMessage for externalLoop.when condition evaluation + await state.set("system.LastMessage", {"Text": accumulated_response}) + + # Store in output variables (.NET style) + if messages_var: + output_path = _map_variable_to_path(messages_var) + await state.set(output_path, all_messages if all_messages else accumulated_response) + + if response_obj_var: + output_path = _map_variable_to_path(response_obj_var) + # Try to parse as JSON for structured output + try: + parsed = json.loads(accumulated_response) if accumulated_response else None + await state.set(output_path, parsed) + except (json.JSONDecodeError, TypeError): + await state.set(output_path, accumulated_response) + + # Store in result property (Python style) + if result_property: + await state.set(result_property, accumulated_response) + + return accumulated_response, all_messages, tool_calls + + @handler + async def handle_action( + self, + trigger: Any, + ctx: WorkflowContext[ActionComplete, str], + ) -> None: + """Handle the agent invocation with full .NET feature parity. + + When externalLoop.when is configured and evaluates to true after agent response, + this method emits an ExternalInputRequest via ctx.request_info() and returns. + The workflow will yield, and when the caller provides a response via + send_responses_streaming(), the handle_external_input_response handler + will continue the loop. + """ + state = await self._ensure_state_initialized(ctx, trigger) + + # Parse configuration + agent_name = self._get_agent_name(state) + if not agent_name: + logger.warning("InvokeAzureAgent action missing 'agent' or 'agent.name' property") + await ctx.send_message(ActionComplete()) + return + + arguments, messages_expr, external_loop_when, max_iterations = self._get_input_config() + messages_var, response_obj_var, result_property, auto_send = self._get_output_config() + + # Get conversation-specific messages path if conversationId is specified + conversation_id_expr = self._get_conversation_id() + messages_path = await self._get_conversation_messages_path(state, conversation_id_expr) + + # Build input + input_text = await self._build_input_text(state, arguments, messages_expr) + + # Get agent from registry + agent: Any = self._agents.get(agent_name) if self._agents else None + if agent is None: + try: + agent_registry: dict[str, Any] | None = await ctx.shared_state.get(AGENT_REGISTRY_KEY) + except KeyError: + agent_registry = {} + agent = agent_registry.get(agent_name) if agent_registry else None + + if agent is None: + error_msg = f"Agent '{agent_name}' not found in registry" + logger.error(f"InvokeAzureAgent: {error_msg}") + await state.set("agent.error", error_msg) + if result_property: + await state.set(result_property, {"error": error_msg}) + raise AgentInvocationError(agent_name, "not found in registry") + + iteration = 0 + + try: + accumulated_response, all_messages, tool_calls = await self._invoke_agent_and_store_results( + agent=agent, + agent_name=agent_name, + input_text=input_text, + state=state, + ctx=ctx, + messages_var=messages_var, + response_obj_var=response_obj_var, + result_property=result_property, + auto_send=auto_send, + messages_path=messages_path, + ) + except AgentInvocationError: + raise # Re-raise our own errors + except Exception as e: + logger.error(f"InvokeAzureAgent: error invoking agent '{agent_name}': {e}") + await state.set("agent.error", str(e)) + if result_property: + await state.set(result_property, {"error": str(e)}) + raise AgentInvocationError(agent_name, str(e)) from e + + # Check external loop condition + if external_loop_when: + should_continue = await state.eval(external_loop_when) + should_continue = bool(should_continue) if should_continue is not None else False + + logger.debug( + f"InvokeAzureAgent: external loop condition '{str(external_loop_when)[:50]}' = " + f"{should_continue} (iteration {iteration})" + ) + + if should_continue: + # Save loop state for resumption + loop_state = ExternalLoopState( + agent_name=agent_name, + iteration=iteration + 1, + external_loop_when=external_loop_when, + messages_var=messages_var, + response_obj_var=response_obj_var, + result_property=result_property, + auto_send=auto_send, + messages_path=messages_path, + max_iterations=max_iterations, + ) + await ctx.shared_state.set(EXTERNAL_LOOP_STATE_KEY, loop_state) + + # Emit request for external input - workflow will yield here + request = ExternalInputRequest( + request_id=str(uuid.uuid4()), + agent_name=agent_name, + agent_response=accumulated_response, + iteration=iteration, + messages=all_messages, + function_calls=tool_calls, + ) + logger.info(f"InvokeAzureAgent: yielding for external input (iteration {iteration})") + await ctx.request_info(request, ExternalInputResponse) + # Return without sending ActionComplete - workflow yields + return + + # No external loop or condition is false - complete the action + await ctx.send_message(ActionComplete()) + + @response_handler + async def handle_external_input_response( + self, + original_request: ExternalInputRequest, + response: ExternalInputResponse, + ctx: WorkflowContext[ActionComplete, str], + ) -> None: + """Handle response to an ExternalInputRequest and continue the loop. + + This is called when the workflow resumes after yielding for external input. + It continues the agent invocation loop with the user's new input. + """ + state = self._get_state(ctx.shared_state) + + # Retrieve saved loop state + try: + loop_state: ExternalLoopState = await ctx.shared_state.get(EXTERNAL_LOOP_STATE_KEY) + except KeyError: + logger.error("InvokeAzureAgent: external loop state not found, cannot resume") + await ctx.send_message(ActionComplete()) + return + + agent_name = loop_state.agent_name + iteration = loop_state.iteration + external_loop_when = loop_state.external_loop_when + max_iterations = loop_state.max_iterations + + # Get the user's new input + input_text = response.user_input + + # Store the user input in state for condition evaluation + await state.set("turn.userInput", input_text) + await state.set("system.LastMessage", {"Text": input_text}) + + # Check if we should continue BEFORE invoking the agent + # This matches .NET behavior where the condition checks the user's input + should_continue = await state.eval(external_loop_when) + should_continue = bool(should_continue) if should_continue is not None else False + + logger.debug( + f"InvokeAzureAgent: external loop condition '{str(external_loop_when)[:50]}' = " + f"{should_continue} (iteration {iteration}) for input '{input_text[:30]}...'" + ) + + if not should_continue: + # User input caused loop to exit - clean up and complete + with contextlib.suppress(KeyError): + await ctx.shared_state.delete(EXTERNAL_LOOP_STATE_KEY) + await ctx.send_message(ActionComplete()) + return + + # Get agent from registry + agent: Any = self._agents.get(agent_name) if self._agents else None + if agent is None: + try: + agent_registry: dict[str, Any] | None = await ctx.shared_state.get(AGENT_REGISTRY_KEY) + except KeyError: + agent_registry = {} + agent = agent_registry.get(agent_name) if agent_registry else None + + if agent is None: + logger.error(f"InvokeAzureAgent: agent '{agent_name}' not found during loop resumption") + raise AgentInvocationError(agent_name, "not found during loop resumption") + + try: + accumulated_response, all_messages, tool_calls = await self._invoke_agent_and_store_results( + agent=agent, + agent_name=agent_name, + input_text=input_text, + state=state, + ctx=ctx, + messages_var=loop_state.messages_var, + response_obj_var=loop_state.response_obj_var, + result_property=loop_state.result_property, + auto_send=loop_state.auto_send, + messages_path=loop_state.messages_path, + ) + except AgentInvocationError: + raise # Re-raise our own errors + except Exception as e: + logger.error(f"InvokeAzureAgent: error invoking agent '{agent_name}' during loop: {e}") + await state.set("agent.error", str(e)) + raise AgentInvocationError(agent_name, str(e)) from e + + # Re-evaluate the condition AFTER the agent responds + # This is critical: the agent's response may have set NeedsTicket=true or IsResolved=true + should_continue = await state.eval(external_loop_when) + should_continue = bool(should_continue) if should_continue is not None else False + + logger.debug( + f"InvokeAzureAgent: external loop condition after response '{str(external_loop_when)[:50]}' = " + f"{should_continue} (iteration {iteration})" + ) + + if not should_continue: + # Agent response caused loop to exit (e.g., NeedsTicket=true or IsResolved=true) + logger.info( + "InvokeAzureAgent: external loop exited due to condition=false " + "(sending ActionComplete to continue workflow)" + ) + with contextlib.suppress(KeyError): + await ctx.shared_state.delete(EXTERNAL_LOOP_STATE_KEY) + await ctx.send_message(ActionComplete()) + return + + # Continue the loop - condition still true + if iteration < max_iterations: + # Update loop state for next iteration + loop_state.iteration = iteration + 1 + await ctx.shared_state.set(EXTERNAL_LOOP_STATE_KEY, loop_state) + + # Emit another request for external input + request = ExternalInputRequest( + request_id=str(uuid.uuid4()), + agent_name=agent_name, + agent_response=accumulated_response, + iteration=iteration, + messages=all_messages, + function_calls=tool_calls, + ) + logger.info(f"InvokeAzureAgent: yielding for external input (iteration {iteration})") + await ctx.request_info(request, ExternalInputResponse) + return + + logger.warning(f"InvokeAzureAgent: external loop exceeded max iterations ({max_iterations})") + + # Loop complete - clean up and send completion + with contextlib.suppress(KeyError): + await ctx.shared_state.delete(EXTERNAL_LOOP_STATE_KEY) + + await ctx.send_message(ActionComplete()) + + +class InvokeToolExecutor(DeclarativeActionExecutor): + """Executor that invokes a registered tool/function. + + Tools are simpler than agents - they take input, perform an action, + and return a result synchronously (or with a simple async call). + """ + + @handler + async def handle_action( + self, + trigger: Any, + ctx: WorkflowContext[ActionComplete], + ) -> None: + """Handle the tool invocation.""" + state = await self._ensure_state_initialized(ctx, trigger) + + tool_name = self._action_def.get("tool") or self._action_def.get("toolName", "") + input_expr = self._action_def.get("input") + output_property = self._action_def.get("output", {}).get("property") or self._action_def.get("resultProperty") + parameters = self._action_def.get("parameters", {}) + + # Get tools registry + try: + tool_registry: dict[str, Any] | None = await ctx.shared_state.get(TOOL_REGISTRY_KEY) + except KeyError: + tool_registry = {} + + tool: Any = tool_registry.get(tool_name) if tool_registry else None + + if tool is None: + error_msg = f"Tool '{tool_name}' not found in registry" + if output_property: + await state.set(output_property, {"error": error_msg}) + await ctx.send_message(ActionComplete()) + return + + # Build parameters + params: dict[str, Any] = {} + for param_name, param_expression in parameters.items(): + params[param_name] = await state.eval_if_expression(param_expression) + + # Add main input if specified + if input_expr: + params["input"] = await state.eval_if_expression(input_expr) + + try: + # Invoke the tool + if callable(tool): + import inspect + + if inspect.iscoroutinefunction(tool): + result = await tool(**params) + else: + result = tool(**params) + + # Store result + if output_property: + await state.set(output_property, result) + + except Exception as e: + if output_property: + await state.set(output_property, {"error": str(e)}) + await ctx.send_message(ActionComplete()) + return + + await ctx.send_message(ActionComplete()) + + +# Mapping of agent action kinds to executor classes +AGENT_ACTION_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = { + "InvokeAzureAgent": InvokeAzureAgentExecutor, + "InvokeTool": InvokeToolExecutor, +} diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_basic.py b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_basic.py new file mode 100644 index 0000000000..097f91d0be --- /dev/null +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_basic.py @@ -0,0 +1,284 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Basic action executors for the graph-based declarative workflow system. + +These executors handle simple actions like SetValue, SendActivity, etc. +Each action becomes a node in the workflow graph. +""" + +from typing import Any + +from agent_framework._workflows import ( + WorkflowContext, + handler, +) + +from ._declarative_base import ( + ActionComplete, + DeclarativeActionExecutor, +) + + +def _get_variable_path(action_def: dict[str, Any], key: str = "variable") -> str | None: + """Extract variable path from action definition. + + Supports .NET style (variable: Local.VarName) and nested object style (variable: {path: ...}). + """ + variable = action_def.get(key) + if isinstance(variable, str): + return variable + if isinstance(variable, dict): + return variable.get("path") + return action_def.get("path") + + +class SetValueExecutor(DeclarativeActionExecutor): + """Executor for the SetValue action. + + Sets a value in the workflow state at a specified path. + """ + + @handler + async def handle_action( + self, + trigger: Any, + ctx: WorkflowContext[ActionComplete], + ) -> None: + """Handle the SetValue action.""" + state = await self._ensure_state_initialized(ctx, trigger) + + path = self._action_def.get("path") + value = self._action_def.get("value") + + if path: + # Evaluate value if it's an expression + evaluated_value = await state.eval_if_expression(value) + await state.set(path, evaluated_value) + + await ctx.send_message(ActionComplete()) + + +class SetVariableExecutor(DeclarativeActionExecutor): + """Executor for the SetVariable action (.NET style naming).""" + + @handler + async def handle_action( + self, + trigger: Any, + ctx: WorkflowContext[ActionComplete], + ) -> None: + """Handle the SetVariable action.""" + state = await self._ensure_state_initialized(ctx, trigger) + + path = _get_variable_path(self._action_def) + value = self._action_def.get("value") + + if path: + evaluated_value = await state.eval_if_expression(value) + await state.set(path, evaluated_value) + + await ctx.send_message(ActionComplete()) + + +class SetTextVariableExecutor(DeclarativeActionExecutor): + """Executor for the SetTextVariable action.""" + + @handler + async def handle_action( + self, + trigger: Any, + ctx: WorkflowContext[ActionComplete], + ) -> None: + """Handle the SetTextVariable action.""" + state = await self._ensure_state_initialized(ctx, trigger) + + path = _get_variable_path(self._action_def) + text = self._action_def.get("text", "") + + if path: + evaluated_text = await state.eval_if_expression(text) + await state.set(path, str(evaluated_text) if evaluated_text is not None else "") + + await ctx.send_message(ActionComplete()) + + +class SetMultipleVariablesExecutor(DeclarativeActionExecutor): + """Executor for the SetMultipleVariables action.""" + + @handler + async def handle_action( + self, + trigger: Any, + ctx: WorkflowContext[ActionComplete], + ) -> None: + """Handle the SetMultipleVariables action.""" + state = await self._ensure_state_initialized(ctx, trigger) + + assignments = self._action_def.get("assignments", []) + for assignment in assignments: + variable = assignment.get("variable") + path: str | None + if isinstance(variable, str): + path = variable + elif isinstance(variable, dict): + path = variable.get("path") + else: + path = assignment.get("path") + value = assignment.get("value") + if path: + evaluated_value = await state.eval_if_expression(value) + await state.set(path, evaluated_value) + + await ctx.send_message(ActionComplete()) + + +class AppendValueExecutor(DeclarativeActionExecutor): + """Executor for the AppendValue action.""" + + @handler + async def handle_action( + self, + trigger: Any, + ctx: WorkflowContext[ActionComplete], + ) -> None: + """Handle the AppendValue action.""" + state = await self._ensure_state_initialized(ctx, trigger) + + path = self._action_def.get("path") + value = self._action_def.get("value") + + if path: + evaluated_value = await state.eval_if_expression(value) + await state.append(path, evaluated_value) + + await ctx.send_message(ActionComplete()) + + +class ResetVariableExecutor(DeclarativeActionExecutor): + """Executor for the ResetVariable action.""" + + @handler + async def handle_action( + self, + trigger: Any, + ctx: WorkflowContext[ActionComplete], + ) -> None: + """Handle the ResetVariable action.""" + state = await self._ensure_state_initialized(ctx, trigger) + + path = _get_variable_path(self._action_def) + + if path: + # Reset to None/empty + await state.set(path, None) + + await ctx.send_message(ActionComplete()) + + +class ClearAllVariablesExecutor(DeclarativeActionExecutor): + """Executor for the ClearAllVariables action.""" + + @handler + async def handle_action( + self, + trigger: Any, + ctx: WorkflowContext[ActionComplete], + ) -> None: + """Handle the ClearAllVariables action.""" + state = await self._ensure_state_initialized(ctx, trigger) + + # Get state data and clear turn variables + state_data = await state.get_state_data() + state_data["turn"] = {} + await state.set_state_data(state_data) + + await ctx.send_message(ActionComplete()) + + +class SendActivityExecutor(DeclarativeActionExecutor): + """Executor for the SendActivity action. + + Sends a text message or activity as workflow output. + """ + + @handler + async def handle_action( + self, + trigger: Any, + ctx: WorkflowContext[ActionComplete, str], + ) -> None: + """Handle the SendActivity action.""" + state = await self._ensure_state_initialized(ctx, trigger) + + activity = self._action_def.get("activity", "") + + # Activity can be a string directly or a dict with a "text" field + text = activity.get("text", "") if isinstance(activity, dict) else activity + + if isinstance(text, str): + # First evaluate any =expression syntax + text = await state.eval_if_expression(text) + # Then interpolate any {Variable.Path} template syntax + if isinstance(text, str): + text = await state.interpolate_string(text) + + # Yield the text as workflow output + if text: + await ctx.yield_output(str(text)) + + await ctx.send_message(ActionComplete()) + + +class EmitEventExecutor(DeclarativeActionExecutor): + """Executor for the EmitEvent action. + + Emits a custom event to the workflow event stream. + + Supports two schema formats: + 1. Graph mode: eventName, eventValue + 2. Interpreter mode: event.name, event.data + """ + + @handler + async def handle_action( + self, + trigger: Any, + ctx: WorkflowContext[ActionComplete, dict[str, Any]], + ) -> None: + """Handle the EmitEvent action.""" + state = await self._ensure_state_initialized(ctx, trigger) + + # Support both schema formats: + # - Graph mode: eventName, eventValue + # - Interpreter mode: event.name, event.data + event_def = self._action_def.get("event", {}) + event_name = self._action_def.get("eventName") or event_def.get("name", "") + event_value = self._action_def.get("eventValue") + if event_value is None: + event_value = event_def.get("data") + + if event_name: + evaluated_name = await state.eval_if_expression(event_name) + evaluated_value = await state.eval_if_expression(event_value) + + event_data = { + "eventName": evaluated_name, + "eventValue": evaluated_value, + } + await ctx.yield_output(event_data) + + await ctx.send_message(ActionComplete()) + + +# Mapping of action kinds to executor classes +BASIC_ACTION_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = { + "SetValue": SetValueExecutor, + "SetVariable": SetVariableExecutor, + "SetTextVariable": SetTextVariableExecutor, + "SetMultipleVariables": SetMultipleVariablesExecutor, + "AppendValue": AppendValueExecutor, + "ResetVariable": ResetVariableExecutor, + "ClearAllVariables": ClearAllVariablesExecutor, + "SendActivity": SendActivityExecutor, + "EmitEvent": EmitEventExecutor, +} diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_control_flow.py b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_control_flow.py new file mode 100644 index 0000000000..9c24fd9d55 --- /dev/null +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_control_flow.py @@ -0,0 +1,509 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Control flow executors for the graph-based declarative workflow system. + +Control flow in the graph-based system is handled differently than the interpreter: +- If/Switch: Condition evaluation happens in a dedicated evaluator executor that + returns a ConditionResult with the first-matching branch index. Edge conditions + then check the branch_index to route to the correct branch. This ensures only + one branch executes (first-match semantics), matching the interpreter behavior. +- Foreach: Loop iteration state managed in SharedState + loop edges +- Goto: Edge to target action (handled by builder) +- Break/Continue: Special signals for loop control + +The key insight is that control flow becomes GRAPH STRUCTURE, not executor logic. +""" + +from typing import Any, cast + +from agent_framework._workflows import ( + WorkflowContext, + handler, +) + +from ._declarative_base import ( + ActionComplete, + ActionTrigger, + ConditionResult, + DeclarativeActionExecutor, + LoopControl, + LoopIterationResult, +) + +# Keys for loop state in SharedState +LOOP_STATE_KEY = "_declarative_loop_state" + +# Index value indicating the else/default branch +ELSE_BRANCH_INDEX = -1 + + +class ConditionGroupEvaluatorExecutor(DeclarativeActionExecutor): + """Evaluates conditions for ConditionGroup/Switch and outputs the first-matching branch. + + This executor implements first-match semantics by evaluating conditions sequentially + and outputting a ConditionResult with the index of the first matching branch. + Edge conditions downstream check this index to route to the correct branch. + + This mirrors .NET's ConditionGroupExecutor.ExecuteAsync which returns the step ID + of the first matching condition. + """ + + def __init__( + self, + action_def: dict[str, Any], + conditions: list[dict[str, Any]], + *, + id: str | None = None, + ): + """Initialize the condition evaluator. + + Args: + action_def: The ConditionGroup/Switch action definition + conditions: List of condition items, each with 'condition' and optional 'id' + id: Optional executor ID + """ + super().__init__(action_def, id=id) + self._conditions = conditions + + @handler + async def handle_action( + self, + trigger: Any, + ctx: WorkflowContext[ConditionResult], + ) -> None: + """Evaluate conditions and output the first matching branch index.""" + state = await self._ensure_state_initialized(ctx, trigger) + + # Evaluate conditions sequentially - first match wins + for index, cond_item in enumerate(self._conditions): + condition_expr = cond_item.get("condition") + if condition_expr is None: + continue + + # Normalize boolean conditions + if condition_expr is True: + condition_expr = "=true" + elif condition_expr is False: + condition_expr = "=false" + elif isinstance(condition_expr, str) and not condition_expr.startswith("="): + condition_expr = f"={condition_expr}" + + result = await state.eval(condition_expr) + if bool(result): + # First matching condition found + await ctx.send_message(ConditionResult(matched=True, branch_index=index, value=result)) + return + + # No condition matched - use else/default branch + await ctx.send_message(ConditionResult(matched=False, branch_index=ELSE_BRANCH_INDEX)) + + +class SwitchEvaluatorExecutor(DeclarativeActionExecutor): + """Evaluates a Switch action by matching a value against cases. + + The Switch action uses a different schema than ConditionGroup: + - value: expression to evaluate once + - cases: list of {match: value_to_match, actions: [...]} + - default: default actions if no case matches + + This evaluator evaluates the value expression once, then compares it + against each case's match value sequentially. First match wins. + """ + + def __init__( + self, + action_def: dict[str, Any], + cases: list[dict[str, Any]], + *, + id: str | None = None, + ): + """Initialize the switch evaluator. + + Args: + action_def: The Switch action definition (contains 'value' expression) + cases: List of case items, each with 'match' and optional 'actions' + id: Optional executor ID + """ + super().__init__(action_def, id=id) + self._cases = cases + + @handler + async def handle_action( + self, + trigger: Any, + ctx: WorkflowContext[ConditionResult], + ) -> None: + """Evaluate the switch value and find the first matching case.""" + state = await self._ensure_state_initialized(ctx, trigger) + + value_expr = self._action_def.get("value") + if not value_expr: + # No value to switch on - use default + await ctx.send_message(ConditionResult(matched=False, branch_index=ELSE_BRANCH_INDEX)) + return + + # Evaluate the switch value once + switch_value = await state.eval_if_expression(value_expr) + + # Compare against each case's match value + for index, case_item in enumerate(self._cases): + match_expr = case_item.get("match") + if match_expr is None: + continue + + # Evaluate the match value + match_value = await state.eval_if_expression(match_expr) + + if switch_value == match_value: + # Found matching case + await ctx.send_message(ConditionResult(matched=True, branch_index=index, value=switch_value)) + return + + # No case matched - use default branch + await ctx.send_message(ConditionResult(matched=False, branch_index=ELSE_BRANCH_INDEX)) + + +class IfConditionEvaluatorExecutor(DeclarativeActionExecutor): + """Evaluates a single If condition and outputs a ConditionResult. + + This is simpler than ConditionGroupEvaluator - just evaluates one condition + and outputs branch_index=0 (then) or branch_index=-1 (else). + """ + + def __init__( + self, + action_def: dict[str, Any], + condition_expr: str, + *, + id: str | None = None, + ): + """Initialize the if condition evaluator. + + Args: + action_def: The If action definition + condition_expr: The condition expression to evaluate + id: Optional executor ID + """ + super().__init__(action_def, id=id) + self._condition_expr = condition_expr + + @handler + async def handle_action( + self, + trigger: Any, + ctx: WorkflowContext[ConditionResult], + ) -> None: + """Evaluate the condition and output the result.""" + state = await self._ensure_state_initialized(ctx, trigger) + + result = await state.eval(self._condition_expr) + is_truthy = bool(result) + + if is_truthy: + await ctx.send_message(ConditionResult(matched=True, branch_index=0, value=result)) + else: + await ctx.send_message(ConditionResult(matched=False, branch_index=ELSE_BRANCH_INDEX, value=result)) + + +class ForeachInitExecutor(DeclarativeActionExecutor): + """Initializes a foreach loop. + + Sets up the loop state in SharedState and determines if there are items. + """ + + @handler + async def handle_action( + self, + trigger: Any, + ctx: WorkflowContext[LoopIterationResult], + ) -> None: + """Initialize the loop and check for first item.""" + state = await self._ensure_state_initialized(ctx, trigger) + + # Support multiple schema formats: + # - Graph mode: itemsSource, items + # - Interpreter mode: source + items_expr = ( + self._action_def.get("itemsSource") or self._action_def.get("items") or self._action_def.get("source") + ) + items_raw: Any = await state.eval_if_expression(items_expr) or [] + + items: list[Any] + items = (list(items_raw) if items_raw else []) if not isinstance(items_raw, (list, tuple)) else list(items_raw) # type: ignore + + loop_id = self.id + + # Store loop state + state_data = await state.get_state_data() + loop_states: dict[str, Any] = cast(dict[str, Any], state_data).setdefault(LOOP_STATE_KEY, {}) + loop_states[loop_id] = { + "items": items, + "index": 0, + "length": len(items), + } + await state.set_state_data(state_data) + + # Check if we have items + if items: + # Set the iteration variable + # Support multiple schema formats: + # - Graph mode: iteratorVariable, item (default "turn.item") + # - Interpreter mode: itemName (default "item", stored in turn scope) + item_var = self._action_def.get("iteratorVariable") or self._action_def.get("item") + if not item_var: + # Interpreter mode: itemName defaults to "item", store in turn scope + item_name = self._action_def.get("itemName", "item") + item_var = f"turn.{item_name}" + + # Support multiple schema formats for index: + # - Graph mode: indexVariable, index + # - Interpreter mode: indexName (default "index", stored in turn scope) + index_var = self._action_def.get("indexVariable") or self._action_def.get("index") + if not index_var and "indexName" in self._action_def: + index_name = self._action_def.get("indexName", "index") + index_var = f"turn.{index_name}" + + await state.set(item_var, items[0]) + if index_var: + await state.set(index_var, 0) + + await ctx.send_message(LoopIterationResult(has_next=True, current_item=items[0], current_index=0)) + else: + await ctx.send_message(LoopIterationResult(has_next=False)) + + +class ForeachNextExecutor(DeclarativeActionExecutor): + """Advances to the next item in a foreach loop. + + This executor is triggered after the loop body completes. + """ + + def __init__( + self, + action_def: dict[str, Any], + init_executor_id: str, + *, + id: str | None = None, + ): + """Initialize with reference to the init executor. + + Args: + action_def: The Foreach action definition + init_executor_id: ID of the corresponding ForeachInitExecutor + id: Optional executor ID + """ + super().__init__(action_def, id=id) + self._init_executor_id = init_executor_id + + @handler + async def handle_action( + self, + trigger: Any, + ctx: WorkflowContext[LoopIterationResult], + ) -> None: + """Advance to next item and send result.""" + state = await self._ensure_state_initialized(ctx, trigger) + + loop_id = self._init_executor_id + + # Get loop state + state_data = await state.get_state_data() + loop_states: dict[str, Any] = cast(dict[str, Any], state_data).get(LOOP_STATE_KEY, {}) + loop_state = loop_states.get(loop_id) + + if not loop_state: + # No loop state - shouldn't happen but handle gracefully + await ctx.send_message(LoopIterationResult(has_next=False)) + return + + items = loop_state["items"] + current_index = loop_state["index"] + 1 + + if current_index < len(items): + # Update loop state + loop_state["index"] = current_index + await state.set_state_data(state_data) + + # Set the iteration variable + # Support multiple schema formats: + # - Graph mode: iteratorVariable, item (default "turn.item") + # - Interpreter mode: itemName (default "item", stored in turn scope) + item_var = self._action_def.get("iteratorVariable") or self._action_def.get("item") + if not item_var: + # Interpreter mode: itemName defaults to "item", store in turn scope + item_name = self._action_def.get("itemName", "item") + item_var = f"turn.{item_name}" + + # Support multiple schema formats for index: + # - Graph mode: indexVariable, index + # - Interpreter mode: indexName (default "index", stored in turn scope) + index_var = self._action_def.get("indexVariable") or self._action_def.get("index") + if not index_var and "indexName" in self._action_def: + index_name = self._action_def.get("indexName", "index") + index_var = f"turn.{index_name}" + + await state.set(item_var, items[current_index]) + if index_var: + await state.set(index_var, current_index) + + await ctx.send_message( + LoopIterationResult(has_next=True, current_item=items[current_index], current_index=current_index) + ) + else: + # Loop complete - clean up + loop_states_dict = cast(dict[str, Any], state_data).get(LOOP_STATE_KEY, {}) + if loop_id in loop_states_dict: + del loop_states_dict[loop_id] + await state.set_state_data(state_data) + + await ctx.send_message(LoopIterationResult(has_next=False)) + + @handler + async def handle_loop_control( + self, + control: LoopControl, + ctx: WorkflowContext[LoopIterationResult], + ) -> None: + """Handle break/continue signals.""" + state = self._get_state(ctx.shared_state) + + if control.action == "break": + # Clean up loop state and signal done + state_data = await state.get_state_data() + loop_states: dict[str, Any] = cast(dict[str, Any], state_data).get(LOOP_STATE_KEY, {}) + if self._init_executor_id in loop_states: + del loop_states[self._init_executor_id] + await state.set_state_data(state_data) + + await ctx.send_message(LoopIterationResult(has_next=False)) + + elif control.action == "continue": + # Just advance to next iteration + await self.handle_action(ActionTrigger(), ctx) + + +class BreakLoopExecutor(DeclarativeActionExecutor): + """Executor for BreakLoop action. + + Sends a LoopControl signal to break out of the enclosing loop. + """ + + def __init__( + self, + action_def: dict[str, Any], + loop_next_executor_id: str, + *, + id: str | None = None, + ): + """Initialize with reference to the loop's next executor. + + Args: + action_def: The action definition + loop_next_executor_id: ID of the ForeachNextExecutor to signal + id: Optional executor ID + """ + super().__init__(action_def, id=id) + self._loop_next_executor_id = loop_next_executor_id + + @handler + async def handle_action( + self, + trigger: Any, + ctx: WorkflowContext[LoopControl], + ) -> None: + """Send break signal to the loop.""" + await ctx.send_message(LoopControl(action="break")) + + +class ContinueLoopExecutor(DeclarativeActionExecutor): + """Executor for ContinueLoop action. + + Sends a LoopControl signal to continue to next iteration. + """ + + def __init__( + self, + action_def: dict[str, Any], + loop_next_executor_id: str, + *, + id: str | None = None, + ): + """Initialize with reference to the loop's next executor. + + Args: + action_def: The action definition + loop_next_executor_id: ID of the ForeachNextExecutor to signal + id: Optional executor ID + """ + super().__init__(action_def, id=id) + self._loop_next_executor_id = loop_next_executor_id + + @handler + async def handle_action( + self, + trigger: Any, + ctx: WorkflowContext[LoopControl], + ) -> None: + """Send continue signal to the loop.""" + await ctx.send_message(LoopControl(action="continue")) + + +class EndWorkflowExecutor(DeclarativeActionExecutor): + """Executor for EndWorkflow/EndDialog action. + + This executor simply doesn't send any message, causing the workflow + to terminate at this point. + """ + + @handler + async def handle_action( + self, + trigger: Any, + ctx: WorkflowContext[ActionComplete], + ) -> None: + """End the workflow by not sending any continuation message.""" + # Don't send ActionComplete - workflow ends here + pass + + +class EndConversationExecutor(DeclarativeActionExecutor): + """Executor for EndConversation action.""" + + @handler + async def handle_action( + self, + trigger: Any, + ctx: WorkflowContext[ActionComplete], + ) -> None: + """End the conversation.""" + # For now, just don't continue + # In a full implementation, this would signal to close the conversation + pass + + +# Passthrough executor for joining control flow branches +class JoinExecutor(DeclarativeActionExecutor): + """Executor that joins multiple branches back together. + + Used after If/Switch to merge control flow back to a single path. + Also used as passthrough nodes for else/default branches. + """ + + @handler + async def handle_action( + self, + trigger: dict[str, Any] | str | ActionTrigger | ActionComplete | ConditionResult | LoopIterationResult, + ctx: WorkflowContext[ActionComplete], + ) -> None: + """Simply pass through to continue the workflow.""" + await ctx.send_message(ActionComplete()) + + +# Mapping of control flow action kinds to executor classes +# Note: Most control flow is handled by the builder creating graph structure, +# these are the executors that are part of that structure +CONTROL_FLOW_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = { + "EndWorkflow": EndWorkflowExecutor, + "EndDialog": EndWorkflowExecutor, + "EndConversation": EndConversationExecutor, +} diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_external_input.py b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_external_input.py new file mode 100644 index 0000000000..c6555a1417 --- /dev/null +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_external_input.py @@ -0,0 +1,266 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""External input executors for declarative workflows. + +These executors handle interactions that require external input (user questions, +confirmations, etc.), using the RequestInfo pattern to pause the workflow and +wait for responses. +""" + +from dataclasses import dataclass +from typing import Any + +from agent_framework._workflows import ( + WorkflowContext, + handler, +) + +from ._declarative_base import ( + ActionComplete, + DeclarativeActionExecutor, +) + + +@dataclass +class QuestionChoice: + """A choice option for a question.""" + + value: str + label: str | None = None + + +@dataclass +class HumanInputRequest: + """Request for human input (triggers workflow pause). + + Used by QuestionExecutor and ConfirmationExecutor to signal that + user input is needed. The workflow will yield this request and + wait for a response. + """ + + request_type: str + message: str + metadata: dict[str, Any] + + +class QuestionExecutor(DeclarativeActionExecutor): + """Executor that asks the user a question and waits for a response. + + This uses the workflow's request_info mechanism to pause execution until + the user provides an answer. The response is stored in workflow state. + """ + + @handler + async def handle_action( + self, + trigger: Any, + ctx: WorkflowContext[ActionComplete, HumanInputRequest], + ) -> None: + """Ask the question and wait for a response.""" + state = await self._ensure_state_initialized(ctx, trigger) + + question_text = self._action_def.get("text") or self._action_def.get("question", "") + output_property = self._action_def.get("output", {}).get("property") or self._action_def.get( + "property", "turn.answer" + ) + choices = self._action_def.get("choices", []) + default_value = self._action_def.get("defaultValue") + allow_free_text = self._action_def.get("allowFreeText", True) + + # Evaluate the question text if it's an expression + evaluated_question = await state.eval_if_expression(question_text) + + # Build choices metadata + choices_data: list[dict[str, str]] | None = None + if choices: + choices_data = [] + for c in choices: + if isinstance(c, dict): + c_dict: dict[str, Any] = dict(c) # type: ignore[arg-type] + choices_data.append({ + "value": c_dict.get("value", ""), + "label": c_dict.get("label") or c_dict.get("value", ""), + }) + else: + choices_data.append({"value": str(c), "label": str(c)}) + + # Yield the request for human input + # The workflow runtime will pause here and return the response when provided + await ctx.yield_output( + HumanInputRequest( + request_type="question", + message=str(evaluated_question), + metadata={ + "output_property": output_property, + "choices": choices_data, + "allow_free_text": allow_free_text, + "default_value": default_value, + }, + ) + ) + + # Note: In a full implementation, the workflow would pause here + # and resume with the response. For now, we just use default. + answer = default_value + + # Store the answer + if output_property: + await state.set(output_property, answer) + + await ctx.send_message(ActionComplete()) + + +class ConfirmationExecutor(DeclarativeActionExecutor): + """Executor that asks for a yes/no confirmation. + + This is a specialized version of Question that returns a boolean. + """ + + @handler + async def handle_action( + self, + trigger: Any, + ctx: WorkflowContext[ActionComplete, HumanInputRequest], + ) -> None: + """Ask for confirmation.""" + state = await self._ensure_state_initialized(ctx, trigger) + + message = self._action_def.get("text") or self._action_def.get("message", "") + output_property = self._action_def.get("output", {}).get("property") or self._action_def.get( + "property", "turn.confirmed" + ) + yes_label = self._action_def.get("yesLabel", "Yes") + no_label = self._action_def.get("noLabel", "No") + default_value = self._action_def.get("defaultValue", False) + + # Evaluate the message if it's an expression + evaluated_message = await state.eval_if_expression(message) + + # Yield the request for confirmation + await ctx.yield_output( + HumanInputRequest( + request_type="confirmation", + message=str(evaluated_message), + metadata={ + "output_property": output_property, + "yes_label": yes_label, + "no_label": no_label, + "default_value": default_value, + }, + ) + ) + + # Store the default value + if output_property: + await state.set(output_property, default_value) + + await ctx.send_message(ActionComplete()) + + +class WaitForInputExecutor(DeclarativeActionExecutor): + """Executor that waits for user input during a conversation turn. + + This is used when the workflow needs to pause and wait for the next + user message in a conversational flow. + """ + + @handler + async def handle_action( + self, + trigger: Any, + ctx: WorkflowContext[ActionComplete, HumanInputRequest | str], + ) -> None: + """Wait for user input.""" + state = await self._ensure_state_initialized(ctx, trigger) + + prompt = self._action_def.get("prompt") + output_property = self._action_def.get("output", {}).get("property") or self._action_def.get( + "property", "turn.input" + ) + timeout_seconds = self._action_def.get("timeout") + + # Emit prompt if specified + if prompt: + evaluated_prompt = await state.eval_if_expression(prompt) + await ctx.yield_output(str(evaluated_prompt)) + + # Yield the request for input + await ctx.yield_output( + HumanInputRequest( + request_type="user_input", + message=str(prompt) if prompt else "Waiting for input...", + metadata={ + "output_property": output_property, + "timeout_seconds": timeout_seconds, + }, + ) + ) + + # Store empty input (will be populated when workflow resumes) + if output_property: + await state.set(output_property, "") + + await ctx.send_message(ActionComplete()) + + +class RequestExternalInputExecutor(DeclarativeActionExecutor): + """Executor that requests external input/approval. + + This is used for more complex external integrations beyond simple questions, + such as approval workflows, document uploads, or external system integrations. + """ + + @handler + async def handle_action( + self, + trigger: Any, + ctx: WorkflowContext[ActionComplete, HumanInputRequest], + ) -> None: + """Request external input.""" + state = await self._ensure_state_initialized(ctx, trigger) + + request_type = self._action_def.get("requestType", "external") + message = self._action_def.get("message", "") + output_property = self._action_def.get("output", {}).get("property") or self._action_def.get( + "property", "turn.externalInput" + ) + timeout_seconds = self._action_def.get("timeout") + required_fields = self._action_def.get("requiredFields", []) + metadata = self._action_def.get("metadata", {}) + + # Evaluate the message if it's an expression + evaluated_message = await state.eval_if_expression(message) + + # Build request metadata + request_metadata: dict[str, Any] = { + **metadata, + "output_property": output_property, + "required_fields": required_fields, + } + + if timeout_seconds: + request_metadata["timeout_seconds"] = timeout_seconds + + # Yield the request + await ctx.yield_output( + HumanInputRequest( + request_type=request_type, + message=str(evaluated_message), + metadata=request_metadata, + ) + ) + + # Store None (will be populated when workflow resumes) + if output_property: + await state.set(output_property, None) + + await ctx.send_message(ActionComplete()) + + +# Mapping of human input action kinds to executor classes +EXTERNAL_INPUT_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = { + "Question": QuestionExecutor, + "Confirmation": ConfirmationExecutor, + "WaitForInput": WaitForInputExecutor, + "RequestExternalInput": RequestExternalInputExecutor, +} diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py b/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py new file mode 100644 index 0000000000..d072a86f32 --- /dev/null +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_factory.py @@ -0,0 +1,676 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""WorkflowFactory creates executable Workflow objects from YAML definitions. + +This module provides the main entry point for declarative workflow support, +parsing YAML workflow definitions and creating Workflow objects that can be +executed using the core workflow runtime. + +Each YAML action becomes a real Executor node in the workflow graph, +enabling checkpointing, visualization, and pause/resume capabilities. +""" + +from collections.abc import Mapping +from pathlib import Path +from typing import Any, cast + +import yaml +from agent_framework import get_logger +from agent_framework._agents import AgentProtocol +from agent_framework._workflows import ( + AgentExecutor, + CheckpointStorage, + Workflow, +) + +from .._loader import AgentFactory +from ._declarative_builder import DeclarativeWorkflowBuilder + +logger = get_logger("agent_framework.declarative.workflows") + + +class DeclarativeWorkflowError(Exception): + """Exception raised for errors in declarative workflow processing.""" + + pass + + +class WorkflowFactory: + """Factory for creating executable Workflow objects from YAML definitions. + + WorkflowFactory parses declarative workflow YAML files and creates + Workflow objects that can be executed using the core workflow runtime. + Each YAML action becomes a real Executor node in the workflow graph, + enabling checkpointing at action boundaries, visualization, and pause/resume. + + Examples: + .. code-block:: python + + from agent_framework.declarative import WorkflowFactory + + # Basic usage: create workflow from YAML file + factory = WorkflowFactory() + workflow = factory.create_workflow_from_yaml_path("workflow.yaml") + + async for event in workflow.run_stream({"query": "Hello"}): + print(event) + + .. code-block:: python + + from agent_framework.declarative import WorkflowFactory + from agent_framework import FileCheckpointStorage + + # With checkpointing for pause/resume support + storage = FileCheckpointStorage(path="./checkpoints") + factory = WorkflowFactory(checkpoint_storage=storage) + workflow = factory.create_workflow_from_yaml_path("workflow.yaml") + + .. code-block:: python + + from agent_framework.azure import AzureOpenAIChatClient + from agent_framework.declarative import WorkflowFactory + + # Pre-register agents for InvokeAzureAgent actions + chat_client = AzureOpenAIChatClient() + agent = chat_client.create_agent(name="MyAgent", instructions="You are helpful.") + + factory = WorkflowFactory(agents={"MyAgent": agent}) + workflow = factory.create_workflow_from_yaml_path("workflow.yaml") + """ + + _agents: dict[str, AgentProtocol | AgentExecutor] + + def __init__( + self, + *, + agent_factory: AgentFactory | None = None, + agents: Mapping[str, AgentProtocol | AgentExecutor] | None = None, + bindings: Mapping[str, Any] | None = None, + env_file: str | None = None, + checkpoint_storage: CheckpointStorage | None = None, + ) -> None: + """Initialize the workflow factory. + + Args: + agent_factory: Optional AgentFactory for creating agents from inline YAML definitions. + agents: Optional pre-created agents by name. These are looked up when processing + InvokeAzureAgent actions in the workflow YAML. + bindings: Optional function bindings for tool calls within workflow actions. + env_file: Optional path to .env file for environment variables used in agent creation. + checkpoint_storage: Optional checkpoint storage enabling pause/resume functionality. + + Examples: + .. code-block:: python + + from agent_framework.declarative import WorkflowFactory + + # Minimal initialization + factory = WorkflowFactory() + + .. code-block:: python + + from agent_framework.azure import AzureOpenAIChatClient + from agent_framework.declarative import WorkflowFactory + + # With pre-registered agents + client = AzureOpenAIChatClient() + agents = { + "WriterAgent": client.create_agent(name="Writer", instructions="Write content."), + "ReviewerAgent": client.create_agent(name="Reviewer", instructions="Review content."), + } + factory = WorkflowFactory(agents=agents) + + .. code-block:: python + + from agent_framework import FileCheckpointStorage + from agent_framework.declarative import WorkflowFactory + + # With checkpoint storage for pause/resume + factory = WorkflowFactory( + checkpoint_storage=FileCheckpointStorage("./checkpoints"), + env_file=".env", + ) + """ + self._agent_factory = agent_factory or AgentFactory(env_file=env_file) + self._agents: dict[str, AgentProtocol | AgentExecutor] = dict(agents) if agents else {} + self._bindings: dict[str, Any] = dict(bindings) if bindings else {} + self._checkpoint_storage = checkpoint_storage + + def create_workflow_from_yaml_path( + self, + yaml_path: str | Path, + ) -> Workflow: + """Create a Workflow from a YAML file path. + + Args: + yaml_path: Path to the YAML workflow definition file. + + Returns: + An executable Workflow object with action nodes for each YAML action. + + Raises: + DeclarativeWorkflowError: If the YAML is invalid or cannot be parsed. + FileNotFoundError: If the YAML file doesn't exist. + + Examples: + .. code-block:: python + + from agent_framework.declarative import WorkflowFactory + + factory = WorkflowFactory() + workflow = factory.create_workflow_from_yaml_path("workflow.yaml") + + # Execute the workflow + async for event in workflow.run_stream({"input": "Hello"}): + print(event) + + .. code-block:: python + + from pathlib import Path + from agent_framework.declarative import WorkflowFactory + + # Using Path object + workflow_path = Path(__file__).parent / "workflows" / "customer_support.yaml" + factory = WorkflowFactory() + workflow = factory.create_workflow_from_yaml_path(workflow_path) + """ + if not isinstance(yaml_path, Path): + yaml_path = Path(yaml_path) + + if not yaml_path.exists(): + raise FileNotFoundError(f"Workflow YAML file not found: {yaml_path}") + + with open(yaml_path) as f: + yaml_content = f.read() + + return self.create_workflow_from_yaml(yaml_content, base_path=yaml_path.parent) + + def create_workflow_from_yaml( + self, + yaml_content: str, + base_path: Path | None = None, + ) -> Workflow: + """Create a Workflow from a YAML string. + + Args: + yaml_content: The YAML workflow definition as a string. + base_path: Optional base path for resolving relative file references + in agent definitions. + + Returns: + An executable Workflow object with action nodes for each YAML action. + + Raises: + DeclarativeWorkflowError: If the YAML is invalid or cannot be parsed. + + Examples: + .. code-block:: python + + from agent_framework.declarative import WorkflowFactory + + yaml_content = ''' + kind: Workflow + trigger: + kind: OnConversationStart + id: greeting_workflow + actions: + - kind: SetVariable + id: set_greeting + variable: Local.Greeting + value: "Hello, World!" + - kind: SendActivity + id: send_greeting + activity: =Local.Greeting + ''' + + factory = WorkflowFactory() + workflow = factory.create_workflow_from_yaml(yaml_content) + + .. code-block:: python + + from pathlib import Path + from agent_framework.declarative import WorkflowFactory + + # With base_path for resolving relative agent file references + yaml_content = ''' + kind: Workflow + agents: + MyAgent: + file: ./agents/my_agent.yaml + trigger: + actions: + - kind: InvokeAzureAgent + agent: + name: MyAgent + ''' + + factory = WorkflowFactory() + workflow = factory.create_workflow_from_yaml( + yaml_content, + base_path=Path("./workflows"), + ) + """ + try: + workflow_def = yaml.safe_load(yaml_content) + except yaml.YAMLError as e: + raise DeclarativeWorkflowError(f"Invalid YAML: {e}") from e + + return self.create_workflow_from_definition(workflow_def, base_path=base_path) + + def create_workflow_from_definition( + self, + workflow_def: dict[str, Any], + base_path: Path | None = None, + ) -> Workflow: + """Create a Workflow from a parsed workflow definition dictionary. + + This is the lowest-level creation method, useful when you already have + a parsed dictionary (e.g., from programmatic construction or custom parsing). + + Args: + workflow_def: The parsed workflow definition dictionary containing + 'kind', 'trigger', 'actions', and optionally 'agents' keys. + base_path: Optional base path for resolving relative file references + in agent definitions. + + Returns: + An executable Workflow object with action nodes for each YAML action. + + Raises: + DeclarativeWorkflowError: If the definition is invalid or missing required fields. + + Examples: + .. code-block:: python + + from agent_framework.declarative import WorkflowFactory + + # Programmatically construct a workflow definition + workflow_def = { + "kind": "Workflow", + "name": "my_workflow", + "trigger": { + "kind": "OnConversationStart", + "id": "main_trigger", + "actions": [ + { + "kind": "SetVariable", + "id": "init", + "variable": "Local.Counter", + "value": 0, + }, + { + "kind": "SendActivity", + "id": "output", + "activity": "Counter initialized", + }, + ], + }, + } + + factory = WorkflowFactory() + workflow = factory.create_workflow_from_definition(workflow_def) + """ + # Validate the workflow definition + self._validate_workflow_def(workflow_def) + + # Extract workflow metadata + # Support both "name" field and trigger.id for workflow name + name: str = workflow_def.get("name", "") + if not name: + trigger: dict[str, Any] = workflow_def.get("trigger", {}) + trigger_id = trigger.get("id", "declarative_workflow") + name = str(trigger_id) if trigger_id else "declarative_workflow" + description = workflow_def.get("description") + + # Create agents from definitions + agents: dict[str, AgentProtocol | AgentExecutor] = dict(self._agents) + agent_defs = workflow_def.get("agents", {}) + + for agent_name, agent_def in agent_defs.items(): + if agent_name in agents: + # Already have this agent + continue + + # Create agent using AgentFactory + try: + agent = self._create_agent_from_def(agent_def, base_path) + agents[agent_name] = agent + logger.debug(f"Created agent '{agent_name}' from definition") + except Exception as e: + logger.error(f"Failed to create agent '{agent_name}': {e}") + raise DeclarativeWorkflowError(f"Failed to create agent '{agent_name}': {e}") from e + + return self._create_workflow(workflow_def, name, description, agents) + + def _create_workflow( + self, + workflow_def: dict[str, Any], + name: str, + description: str | None, + agents: dict[str, AgentProtocol | AgentExecutor], + ) -> Workflow: + """Create workflow from definition. + + Each YAML action becomes a real Executor node in the workflow graph. + This enables checkpointing at action boundaries. + + Args: + workflow_def: The workflow definition + name: Workflow name + description: Workflow description + agents: Registry of agent instances + + Returns: + Workflow with individual action executors as nodes + """ + # Normalize workflow definition to have actions at top level + normalized_def = self._normalize_workflow_def(workflow_def) + normalized_def["name"] = name + if description: + normalized_def["description"] = description + + # Build the graph-based workflow, passing agents for InvokeAzureAgent executors + try: + graph_builder = DeclarativeWorkflowBuilder( + normalized_def, + workflow_id=name, + agents=agents, + checkpoint_storage=self._checkpoint_storage, + ) + workflow = graph_builder.build() + except ValueError as e: + raise DeclarativeWorkflowError(f"Failed to build graph-based workflow: {e}") from e + + # Store agents and bindings for reference (executors already have them) + workflow._declarative_agents = agents # type: ignore[attr-defined] + workflow._declarative_bindings = self._bindings # type: ignore[attr-defined] + + # Store input schema if defined in workflow definition + # This allows DevUI to generate proper input forms + if "inputs" in workflow_def: + workflow.input_schema = self._convert_inputs_to_json_schema(workflow_def["inputs"]) # type: ignore[attr-defined] + + logger.debug( + "Created graph-based workflow '%s' with %d executors", + name, + len(graph_builder._executors), # type: ignore[reportPrivateUsage] + ) + + return workflow + + def _normalize_workflow_def(self, workflow_def: dict[str, Any]) -> dict[str, Any]: + """Normalize workflow definition to have actions at top level. + + Args: + workflow_def: The workflow definition + + Returns: + Normalized definition with actions at top level + """ + actions = self._get_actions_from_def(workflow_def) + return { + **workflow_def, + "actions": actions, + } + + def _validate_workflow_def(self, workflow_def: dict[str, Any]) -> None: + """Validate a workflow definition. + + Args: + workflow_def: The workflow definition to validate + + Raises: + DeclarativeWorkflowError: If the definition is invalid + """ + if not isinstance(workflow_def, dict): + raise DeclarativeWorkflowError("Workflow definition must be a dictionary") + + # Handle both formats: + # 1. Direct actions list: {"actions": [...]} + # 2. Trigger-based: {"kind": "Workflow", "trigger": {"actions": [...]}} + actions = self._get_actions_from_def(workflow_def) + + if not isinstance(actions, list): + raise DeclarativeWorkflowError("Workflow 'actions' must be a list") + + # Validate each action has a kind + for i, action in enumerate(actions): + if not isinstance(action, dict): + raise DeclarativeWorkflowError(f"Action at index {i} must be a dictionary") + if "kind" not in action: + raise DeclarativeWorkflowError(f"Action at index {i} missing 'kind' field") + + def _get_actions_from_def(self, workflow_def: dict[str, Any]) -> list[dict[str, Any]]: + """Extract actions from a workflow definition. + + Handles both direct actions format and trigger-based format. + + Args: + workflow_def: The workflow definition + + Returns: + List of action definitions + + Raises: + DeclarativeWorkflowError: If no actions can be found + """ + # Try direct actions first + if "actions" in workflow_def: + actions: list[dict[str, Any]] = workflow_def["actions"] + return actions + + # Try trigger-based format + if "trigger" in workflow_def: + trigger = workflow_def["trigger"] + if isinstance(trigger, dict) and "actions" in trigger: + trigger_actions: list[dict[str, Any]] = list(trigger["actions"]) # type: ignore[arg-type] + return trigger_actions + + raise DeclarativeWorkflowError("Workflow definition must have 'actions' field or 'trigger.actions' field") + + def _create_agent_from_def( + self, + agent_def: dict[str, Any], + base_path: Path | None = None, + ) -> Any: + """Create an agent from a definition. + + Args: + agent_def: The agent definition dictionary + base_path: Optional base path for resolving relative file references + + Returns: + An agent instance + """ + # Check if it's a reference to an external file + if "file" in agent_def: + file_path = agent_def["file"] + if base_path and not Path(file_path).is_absolute(): + file_path = base_path / file_path + return self._agent_factory.create_agent_from_yaml_path(file_path) + + # Check if it's an inline agent definition + if "kind" in agent_def: + return self._agent_factory.create_agent_from_dict(agent_def) + + # Handle connection-based agent (like Azure AI agents) + if "connection" in agent_def: + # This would create a hosted agent client + # For now, we'll need the user to provide pre-created agents + raise DeclarativeWorkflowError( + "Connection-based agents must be provided via the 'agents' parameter. " + "Create the agent using the appropriate client and pass it to WorkflowFactory." + ) + + raise DeclarativeWorkflowError( + f"Invalid agent definition. Expected 'file', 'kind', or 'connection': {agent_def}" + ) + + def register_agent(self, name: str, agent: AgentProtocol | AgentExecutor) -> "WorkflowFactory": + """Register an agent instance with the factory for use in workflows. + + Registered agents are available to InvokeAzureAgent actions by name. + This method supports fluent chaining. + + Args: + name: The name to register the agent under. Must match the agent name + referenced in InvokeAzureAgent actions. + agent: The agent instance (typically a ChatAgent or similar). + + Returns: + Self for method chaining. + + Examples: + .. code-block:: python + + from agent_framework.azure import AzureOpenAIChatClient + from agent_framework.declarative import WorkflowFactory + + client = AzureOpenAIChatClient() + + # Method chaining to register multiple agents + factory = ( + WorkflowFactory() + .register_agent( + "Writer", + client.create_agent( + name="Writer", + instructions="Write content.", + ), + ) + .register_agent( + "Reviewer", + client.create_agent( + name="Reviewer", + instructions="Review content.", + ), + ) + ) + + workflow = factory.create_workflow_from_yaml_path("workflow.yaml") + """ + self._agents[name] = agent + return self + + def register_binding(self, name: str, func: Any) -> "WorkflowFactory": + """Register a function binding with the factory for use in workflow actions. + + Bindings allow workflow actions to invoke Python functions by name. + This method supports fluent chaining. + + Args: + name: The name to register the function under. + func: The function to bind. + + Returns: + Self for method chaining. + + Examples: + .. code-block:: python + + from agent_framework.declarative import WorkflowFactory + + + def get_weather(location: str) -> str: + return f"Weather in {location}: Sunny, 72F" + + + def send_email(to: str, subject: str, body: str) -> bool: + # Send email logic + return True + + + # Register functions for use in workflow + factory = ( + WorkflowFactory() + .register_binding("get_weather", get_weather) + .register_binding("send_email", send_email) + ) + + workflow = factory.create_workflow_from_yaml_path("workflow.yaml") + """ + self._bindings[name] = func + return self + + def _convert_inputs_to_json_schema(self, inputs_def: dict[str, Any]) -> dict[str, Any]: + """Convert a declarative inputs definition to JSON Schema. + + The inputs definition uses a simplified format: + inputs: + age: + type: integer + description: The user's age + name: + type: string + + This is converted to standard JSON Schema format. + + Args: + inputs_def: The inputs definition from the workflow YAML + + Returns: + A JSON Schema object + """ + properties: dict[str, Any] = {} + required: list[str] = [] + + for field_name, field_def in inputs_def.items(): + if isinstance(field_def, dict): + # Field has type and possibly other attributes + prop: dict[str, Any] = {} + field_def_dict: dict[str, Any] = cast(dict[str, Any], field_def) + field_type: str = str(field_def_dict.get("type", "string")) + + # Map declarative types to JSON Schema types + type_mapping: dict[str, str] = { + "string": "string", + "str": "string", + "integer": "integer", + "int": "integer", + "number": "number", + "float": "number", + "boolean": "boolean", + "bool": "boolean", + "array": "array", + "list": "array", + "object": "object", + "dict": "object", + } + prop["type"] = type_mapping.get(field_type, field_type) + + # Copy other attributes + if "description" in field_def_dict: + prop["description"] = field_def_dict["description"] + if "default" in field_def_dict: + prop["default"] = field_def_dict["default"] + if "enum" in field_def_dict: + prop["enum"] = field_def_dict["enum"] + + # Check if required (default: true unless explicitly false) + if field_def_dict.get("required", True): + required.append(field_name) + + properties[field_name] = prop + else: + # Simple type definition (e.g., "age: integer") + type_mapping_simple: dict[str, str] = { + "string": "string", + "str": "string", + "integer": "integer", + "int": "integer", + "number": "number", + "float": "number", + "boolean": "boolean", + "bool": "boolean", + } + properties[field_name] = {"type": type_mapping_simple.get(str(field_def), "string")} + required.append(field_name) + + schema: dict[str, Any] = { + "type": "object", + "properties": properties, + } + if required: + schema["required"] = required + + return schema diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_handlers.py b/python/packages/declarative/agent_framework_declarative/_workflows/_handlers.py new file mode 100644 index 0000000000..64db7f43f6 --- /dev/null +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_handlers.py @@ -0,0 +1,211 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Action handlers for declarative workflow execution. + +This module provides the ActionHandler protocol and registry for executing +workflow actions defined in YAML. Each action type (InvokeAzureAgent, Foreach, etc.) +has a corresponding handler registered via the @action_handler decorator. +""" + +from collections.abc import AsyncGenerator, Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable + +from agent_framework import get_logger + +if TYPE_CHECKING: + from ._state import WorkflowState + +logger = get_logger("agent_framework.declarative.workflows") + + +@dataclass +class ActionContext: + """Context passed to action handlers during execution. + + Provides access to workflow state, the action definition, and methods + for executing nested actions (for control flow constructs like Foreach). + """ + + state: "WorkflowState" + """The current workflow state with variables and agent results.""" + + action: dict[str, Any] + """The action definition from the YAML.""" + + execute_actions: "ExecuteActionsFn" + """Function to execute a list of nested actions (for Foreach, If, etc.).""" + + agents: dict[str, Any] + """Registry of agent instances by name.""" + + bindings: dict[str, Any] + """Function bindings for tool calls.""" + + @property + def action_id(self) -> str | None: + """Get the action's unique identifier.""" + return self.action.get("id") + + @property + def display_name(self) -> str | None: + """Get the action's human-readable display name for debugging/logging.""" + return self.action.get("displayName") + + @property + def action_kind(self) -> str | None: + """Get the action's type/kind.""" + return self.action.get("kind") + + +# Type alias for the nested action executor function +ExecuteActionsFn = Callable[ + [list[dict[str, Any]], "WorkflowState"], + AsyncGenerator["WorkflowEvent", None], +] + + +@dataclass +class WorkflowEvent: + """Base class for events emitted during workflow execution.""" + + pass + + +@dataclass +class TextOutputEvent(WorkflowEvent): + """Event emitted when text should be sent to the user.""" + + text: str + """The text content to output.""" + + +@dataclass +class AttachmentOutputEvent(WorkflowEvent): + """Event emitted when an attachment should be sent to the user.""" + + content: Any + """The attachment content.""" + + content_type: str = "application/octet-stream" + """The MIME type of the attachment.""" + + +@dataclass +class AgentResponseEvent(WorkflowEvent): + """Event emitted when an agent produces a response.""" + + agent_name: str + """The name of the agent that produced the response.""" + + text: str | None + """The text content of the response, if any.""" + + messages: list[Any] + """The messages from the agent response.""" + + tool_calls: list[Any] | None = None + """Any tool calls made by the agent.""" + + +@dataclass +class AgentStreamingChunkEvent(WorkflowEvent): + """Event emitted for streaming chunks from an agent.""" + + agent_name: str + """The name of the agent producing the chunk.""" + + chunk: str + """The streaming chunk content.""" + + +@dataclass +class CustomEvent(WorkflowEvent): + """Custom event emitted via EmitEvent action.""" + + name: str + """The event name.""" + + data: Any + """The event data.""" + + +@dataclass +class LoopControlSignal(WorkflowEvent): + """Signal for loop control (break/continue).""" + + signal_type: str + """Either 'break' or 'continue'.""" + + +@runtime_checkable +class ActionHandler(Protocol): + """Protocol for action handlers. + + Action handlers are async generators that execute a single action type + and yield events as they process. They receive an ActionContext with + the current state, action definition, and utilities for nested execution. + """ + + def __call__( + self, + ctx: ActionContext, + ) -> AsyncGenerator[WorkflowEvent, None]: + """Execute the action and yield events. + + Args: + ctx: The action context containing state, action definition, and utilities + + Yields: + WorkflowEvent instances as the action executes + """ + ... + + +# Global registry of action handlers +_ACTION_HANDLERS: dict[str, ActionHandler] = {} + + +def action_handler(action_kind: str) -> Callable[[ActionHandler], ActionHandler]: + """Decorator to register an action handler for a specific action type. + + Args: + action_kind: The action type this handler processes (e.g., 'InvokeAzureAgent') + + Example: + @action_handler("SetValue") + async def handle_set_value(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: + path = ctx.action.get("path") + value = ctx.state.eval_if_expression(ctx.action.get("value")) + ctx.state.set(path, value) + return + yield # Make it a generator + """ + + def decorator(func: ActionHandler) -> ActionHandler: + _ACTION_HANDLERS[action_kind] = func + logger.debug(f"Registered action handler for '{action_kind}'") + return func + + return decorator + + +def get_action_handler(action_kind: str) -> ActionHandler | None: + """Get the registered handler for an action type. + + Args: + action_kind: The action type to look up + + Returns: + The registered ActionHandler, or None if not found + """ + return _ACTION_HANDLERS.get(action_kind) + + +def list_action_handlers() -> list[str]: + """List all registered action handler types. + + Returns: + A list of registered action type names + """ + return list(_ACTION_HANDLERS.keys()) diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_human_input.py b/python/packages/declarative/agent_framework_declarative/_workflows/_human_input.py new file mode 100644 index 0000000000..97259807e7 --- /dev/null +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_human_input.py @@ -0,0 +1,320 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Human-in-the-loop action handlers for declarative workflows. + +This module implements handlers for human input patterns: +- Question: Request human input with validation +- RequestExternalInput: Request input from external system +- ExternalLoop processing: Loop while waiting for external input +""" + +from collections.abc import AsyncGenerator +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, cast + +from agent_framework import get_logger + +from ._handlers import ( + ActionContext, + WorkflowEvent, + action_handler, +) + +if TYPE_CHECKING: + from ._state import WorkflowState + +logger = get_logger("agent_framework.declarative.workflows.human_input") + + +@dataclass +class QuestionRequest(WorkflowEvent): + """Event emitted when the workflow needs user input via Question action. + + When this event is yielded, the workflow execution should pause + and wait for user input to be provided via workflow.send_response(). + + This is used by the Question, RequestExternalInput, and WaitForInput + action handlers in the non-graph workflow path. + """ + + request_id: str + """Unique identifier for this request.""" + + prompt: str | None + """The prompt/question to display to the user.""" + + variable: str + """The variable where the response should be stored.""" + + validation: dict[str, Any] | None = None + """Optional validation rules for the input.""" + + choices: list[str] | None = None + """Optional list of valid choices.""" + + default_value: Any = None + """Default value if no input is provided.""" + + +@dataclass +class ExternalLoopEvent(WorkflowEvent): + """Event emitted when entering an external input loop. + + This event signals that the action is waiting for external input + in a loop pattern (e.g., input.externalLoop.when condition). + """ + + action_id: str + """The ID of the action that requires external input.""" + + iteration: int + """The current iteration number (0-based).""" + + condition_expression: str + """The PowerFx condition that must become false to exit the loop.""" + + +@action_handler("Question") +async def handle_question(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: # noqa: RUF029 + """Handle Question action - request human input with optional validation. + + Action schema: + kind: Question + id: ask_name + variable: Local.userName + prompt: What is your name? + validation: + required: true + minLength: 1 + maxLength: 100 + choices: # optional - present as multiple choice + - Option A + - Option B + default: Option A # optional default value + + The handler emits a QuestionRequest and expects the workflow runner + to capture and provide the response before continuing. + """ + question_id = ctx.action.get("id", "question") + variable = ctx.action.get("variable") + prompt = ctx.action.get("prompt") + question: dict[str, Any] | Any = ctx.action.get("question", {}) + validation = ctx.action.get("validation", {}) + choices = ctx.action.get("choices") + default_value = ctx.action.get("default") + + if not variable: + logger.warning("Question action missing 'variable' property") + return + + # Evaluate prompt if it's an expression (support both 'prompt' and 'question.text') + prompt_text: Any | None = None + if isinstance(question, dict): + question_dict: dict[str, Any] = cast(dict[str, Any], question) + prompt_text = prompt or question_dict.get("text") + else: + prompt_text = prompt + evaluated_prompt = ctx.state.eval_if_expression(prompt_text) if prompt_text else None + + # Evaluate choices if they're expressions + evaluated_choices = None + if choices: + evaluated_choices = [ctx.state.eval_if_expression(c) if isinstance(c, str) else c for c in choices] + + logger.debug(f"Question: requesting input for {variable}") + + # Emit the request event + yield QuestionRequest( + request_id=question_id, + prompt=str(evaluated_prompt) if evaluated_prompt else None, + variable=variable, + validation=validation, + choices=evaluated_choices, + default_value=default_value, + ) + + # Apply default value if specified (for non-interactive scenarios) + if default_value is not None: + evaluated_default = ctx.state.eval_if_expression(default_value) + ctx.state.set(variable, evaluated_default) + + +@action_handler("RequestExternalInput") +async def handle_request_external_input(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: # noqa: RUF029 + """Handle RequestExternalInput action - request input from external system. + + Action schema: + kind: RequestExternalInput + id: get_approval + variable: Local.approval + prompt: Please approve or reject the request + timeout: 300 # seconds + default: "No feedback provided" # optional default value + output: + response: Local.approvalResponse + timestamp: Local.approvalTime + + Similar to Question but designed for external system integration + rather than direct human input. + """ + request_id = ctx.action.get("id", "external_input") + variable = ctx.action.get("variable") + prompt = ctx.action.get("prompt") + timeout = ctx.action.get("timeout") # seconds + default_value = ctx.action.get("default") + _output = ctx.action.get("output", {}) # Reserved for future use + + if not variable: + logger.warning("RequestExternalInput action missing 'variable' property") + return + + # Extract prompt text (support both 'prompt' string and 'prompt.text' object) + prompt_text: Any | None = None + if isinstance(prompt, dict): + prompt_dict: dict[str, Any] = cast(dict[str, Any], prompt) + prompt_text = prompt_dict.get("text") + else: + prompt_text = prompt + + # Evaluate prompt if it's an expression + evaluated_prompt = ctx.state.eval_if_expression(prompt_text) if prompt_text else None + + logger.debug(f"RequestExternalInput: requesting input for {variable}") + + # Emit the request event + yield QuestionRequest( + request_id=request_id, + prompt=str(evaluated_prompt) if evaluated_prompt else None, + variable=variable, + validation={"timeout": timeout} if timeout else None, + default_value=default_value, + ) + + # Apply default value if specified (for non-interactive scenarios) + if default_value is not None: + evaluated_default = ctx.state.eval_if_expression(default_value) + ctx.state.set(variable, evaluated_default) + + +@action_handler("WaitForInput") +async def handle_wait_for_input(ctx: ActionContext) -> AsyncGenerator[WorkflowEvent, None]: # noqa: RUF029 + """Handle WaitForInput action - pause and wait for external input. + + Action schema: + kind: WaitForInput + id: wait_for_response + variable: Local.response + message: Waiting for user response... + + This is a simpler form of RequestExternalInput that just pauses + execution until input is provided. + """ + wait_id = ctx.action.get("id", "wait") + variable = ctx.action.get("variable") + message = ctx.action.get("message") + + if not variable: + logger.warning("WaitForInput action missing 'variable' property") + return + + # Evaluate message if it's an expression + evaluated_message = ctx.state.eval_if_expression(message) if message else None + + logger.debug(f"WaitForInput: waiting for {variable}") + + yield QuestionRequest( + request_id=wait_id, + prompt=str(evaluated_message) if evaluated_message else None, + variable=variable, + ) + + +def process_external_loop( + input_config: dict[str, Any], + state: "WorkflowState", +) -> tuple[bool, str | None]: + """Process the externalLoop.when pattern from action input. + + This function evaluates the externalLoop.when condition to determine + if the action should continue looping for external input. + + Args: + input_config: The input configuration containing externalLoop + state: The workflow state for expression evaluation + + Returns: + Tuple of (should_continue_loop, condition_expression) + - should_continue_loop: True if the loop should continue + - condition_expression: The original condition expression for diagnostics + """ + external_loop = input_config.get("externalLoop", {}) + when_condition = external_loop.get("when") + + if not when_condition: + return (False, None) + + # Evaluate the condition + result = state.eval(when_condition) + + # The loop continues while the condition is True + should_continue = bool(result) if result is not None else False + + logger.debug(f"ExternalLoop condition '{when_condition[:50]}' evaluated to {should_continue}") + + return (should_continue, when_condition) + + +def validate_input_response( + value: Any, + validation: dict[str, Any] | None, +) -> tuple[bool, str | None]: + """Validate input response against validation rules. + + Args: + value: The input value to validate + validation: Validation rules from the Question action + + Returns: + Tuple of (is_valid, error_message) + """ + if not validation: + return (True, None) + + # Check required + if validation.get("required") and (value is None or value == ""): + return (False, "This field is required") + + if value is None: + return (True, None) + + # Check string length + if isinstance(value, str): + min_length = validation.get("minLength") + max_length = validation.get("maxLength") + + if min_length is not None and len(value) < min_length: + return (False, f"Minimum length is {min_length}") + + if max_length is not None and len(value) > max_length: + return (False, f"Maximum length is {max_length}") + + # Check numeric range + if isinstance(value, (int, float)): + min_value = validation.get("min") + max_value = validation.get("max") + + if min_value is not None and value < min_value: + return (False, f"Minimum value is {min_value}") + + if max_value is not None and value > max_value: + return (False, f"Maximum value is {max_value}") + + # Check pattern (regex) + pattern = validation.get("pattern") + if pattern and isinstance(value, str): + import re + + if not re.match(pattern, value): + return (False, f"Value does not match pattern: {pattern}") + + return (True, None) diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_powerfx_functions.py b/python/packages/declarative/agent_framework_declarative/_workflows/_powerfx_functions.py new file mode 100644 index 0000000000..ba42c46564 --- /dev/null +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_powerfx_functions.py @@ -0,0 +1,472 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Custom PowerFx-like functions for declarative workflows. + +This module provides Python implementations of custom PowerFx functions +that are used in declarative workflows but may not be available in the +standard PowerFx Python package. + +These functions can be used as fallbacks when PowerFx is not available, +or registered with the PowerFx engine when it is available. +""" + +from typing import Any, cast + + +def message_text(messages: Any) -> str: + """Extract text content from a message or list of messages. + + This is equivalent to the .NET MessageText() function. + + Args: + messages: A message object, list of messages, or string + + Returns: + The concatenated text content of all messages + + Examples: + .. code-block:: python + + message_text([{"role": "assistant", "content": "Hello"}]) + # Returns: 'Hello' + """ + if messages is None: + return "" + + if isinstance(messages, str): + return messages + + if isinstance(messages, dict): + # Single message object + messages_dict = cast(dict[str, Any], messages) + content: Any = messages_dict.get("content", "") + if isinstance(content, str): + return content + if hasattr(content, "text"): + return str(content.text) + return str(content) if content else "" + + if isinstance(messages, list): + # List of messages - concatenate all text + texts: list[str] = [] + for msg in messages: + if isinstance(msg, str): + texts.append(msg) + elif isinstance(msg, dict): + msg_dict = cast(dict[str, Any], msg) + msg_content: Any = msg_dict.get("content", "") + if isinstance(msg_content, str): + texts.append(msg_content) + elif msg_content: + texts.append(str(msg_content)) + elif hasattr(msg, "content"): + msg_obj_content: Any = msg.content + if isinstance(msg_obj_content, str): + texts.append(msg_obj_content) + elif hasattr(msg_obj_content, "text"): + texts.append(str(msg_obj_content.text)) + elif msg_obj_content: + texts.append(str(msg_obj_content)) + return " ".join(texts) + + # Try to get text attribute + if hasattr(messages, "text"): + return str(messages.text) + if hasattr(messages, "content"): + content_attr: Any = messages.content + if isinstance(content_attr, str): + return content_attr + return str(content_attr) if content_attr else "" + + return str(messages) if messages else "" + + +def user_message(text: str) -> dict[str, str]: + """Create a user message object. + + This is equivalent to the .NET UserMessage() function. + + Args: + text: The text content of the message + + Returns: + A message dictionary with role 'user' + + Examples: + .. code-block:: python + + user_message("Hello") + # Returns: {'role': 'user', 'content': 'Hello'} + """ + return {"role": "user", "content": str(text) if text else ""} + + +def assistant_message(text: str) -> dict[str, str]: + """Create an assistant message object. + + Args: + text: The text content of the message + + Returns: + A message dictionary with role 'assistant' + + Examples: + .. code-block:: python + + assistant_message("Hello") + # Returns: {'role': 'assistant', 'content': 'Hello'} + """ + return {"role": "assistant", "content": str(text) if text else ""} + + +def system_message(text: str) -> dict[str, str]: + """Create a system message object. + + Args: + text: The text content of the message + + Returns: + A message dictionary with role 'system' + + Examples: + .. code-block:: python + + system_message("You are a helpful assistant") + # Returns: {'role': 'system', 'content': 'You are a helpful assistant'} + """ + return {"role": "system", "content": str(text) if text else ""} + + +def if_func(condition: Any, true_value: Any, false_value: Any = None) -> Any: + """Conditional expression - returns one value or another based on a condition. + + This is equivalent to the PowerFx If() function. + + Args: + condition: The condition to evaluate (truthy/falsy) + true_value: Value to return if condition is truthy + false_value: Value to return if condition is falsy (defaults to None) + + Returns: + true_value if condition is truthy, otherwise false_value + """ + return true_value if condition else false_value + + +def is_blank(value: Any) -> bool: + """Check if a value is blank (None, empty string, empty list, etc.). + + This is equivalent to the PowerFx IsBlank() function. + + Args: + value: The value to check + + Returns: + True if the value is considered blank + """ + if value is None: + return True + if isinstance(value, str) and not value.strip(): + return True + if isinstance(value, list): + return len(value) == 0 + if isinstance(value, dict): + return len(value) == 0 + return False + + +def or_func(*args: Any) -> bool: + """Logical OR - returns True if any argument is truthy. + + This is equivalent to the PowerFx Or() function. + + Args: + *args: Variable number of values to check + + Returns: + True if any argument is truthy + """ + return any(bool(arg) for arg in args) + + +def and_func(*args: Any) -> bool: + """Logical AND - returns True if all arguments are truthy. + + This is equivalent to the PowerFx And() function. + + Args: + *args: Variable number of values to check + + Returns: + True if all arguments are truthy + """ + return all(bool(arg) for arg in args) + + +def not_func(value: Any) -> bool: + """Logical NOT - returns the opposite boolean value. + + This is equivalent to the PowerFx Not() function. + + Args: + value: The value to negate + + Returns: + True if value is falsy, False if truthy + """ + return not bool(value) + + +def count_rows(table: Any) -> int: + """Count the number of rows/items in a table/list. + + This is equivalent to the PowerFx CountRows() function. + + Args: + table: A list or table-like object + + Returns: + The number of rows/items + """ + if table is None: + return 0 + if isinstance(table, (list, tuple)): + return len(cast(list[Any], table)) + if isinstance(table, dict): + return len(cast(dict[str, Any], table)) + return 0 + + +def first(table: Any) -> Any: + """Get the first item from a table/list. + + This is equivalent to the PowerFx First() function. + + Args: + table: A list or table-like object + + Returns: + The first item, or None if empty + """ + if table is None: + return None + if isinstance(table, (list, tuple)): + table_list = cast(list[Any], table) + if len(table_list) > 0: + return table_list[0] + return None + + +def last(table: Any) -> Any: + """Get the last item from a table/list. + + This is equivalent to the PowerFx Last() function. + + Args: + table: A list or table-like object + + Returns: + The last item, or None if empty + """ + if table is None: + return None + if isinstance(table, (list, tuple)): + table_list = cast(list[Any], table) + if len(table_list) > 0: + return table_list[-1] + return None + + +def find(substring: str | None, text: str | None) -> int | None: + """Find the position of a substring within text. + + This is equivalent to the PowerFx Find() function. + Returns None (Blank) if not found, otherwise 1-based index. + + Args: + substring: The substring to find + text: The text to search in + + Returns: + 1-based index if found, None (Blank) if not found + """ + if substring is None or text is None: + return None + try: + index = str(text).find(str(substring)) + return index + 1 if index >= 0 else None + except (TypeError, ValueError): + return None + + +def upper(text: str | None) -> str: + """Convert text to uppercase. + + This is equivalent to the PowerFx Upper() function. + + Args: + text: The text to convert + + Returns: + Uppercase text + """ + if text is None: + return "" + return str(text).upper() + + +def lower(text: str | None) -> str: + """Convert text to lowercase. + + This is equivalent to the PowerFx Lower() function. + + Args: + text: The text to convert + + Returns: + Lowercase text + """ + if text is None: + return "" + return str(text).lower() + + +def concat_strings(*args: Any) -> str: + """Concatenate multiple string arguments. + + This is equivalent to the PowerFx Concat() function for string concatenation. + + Args: + *args: Variable number of values to concatenate + + Returns: + Concatenated string + """ + return "".join(str(arg) if arg is not None else "" for arg in args) + + +def concat_text(table: Any, field: str | None = None, separator: str = "") -> str: + """Concatenate values from a table/list. + + This is equivalent to the PowerFx Concat() function. + + Args: + table: A list of items + field: Optional field name to extract from each item + separator: Separator between values + + Returns: + Concatenated string + """ + if table is None: + return "" + if not isinstance(table, (list, tuple)): + return str(table) + + values: list[str] = [] + for item in cast(list[Any], table): + value: Any = None + if field and isinstance(item, dict): + item_dict = cast(dict[str, Any], item) + value = item_dict.get(field, "") + elif field and hasattr(item, field): + value = getattr(item, field, "") + else: + value = item + values.append(str(value) if value is not None else "") + + return separator.join(values) + + +def for_all(table: Any, expression: str, field_mapping: dict[str, str] | None = None) -> list[Any]: + """Apply an expression to each row of a table. + + This is equivalent to the PowerFx ForAll() function. + + Args: + table: A list of records + expression: A string expression that references item fields + field_mapping: Optional dict mapping placeholder names to field names + + Returns: + List of results from applying expression to each row + + Note: + The expression can use field names directly from the record. + For example: ForAll(items, "$" & name & ": " & description) + """ + if table is None or not isinstance(table, (list, tuple)): + return [] + + results: list[Any] = [] + for item in cast(list[Any], table): + # If item is a dict, we can directly substitute field values + if isinstance(item, dict): + item_dict = cast(dict[str, Any], item) + # The expression is typically already evaluated by the expression parser + # This function primarily handles table iteration + # Return the item itself for further processing + results.append(item_dict) + else: + results.append(item) + + return results + + +def search_table(table: Any, value: Any, column: str) -> list[Any]: + """Search for rows in a table where a column matches a value. + + This is equivalent to the PowerFx Search() function. + + Args: + table: A list of records + value: The value to search for + column: The column name to search in + + Returns: + List of matching records + """ + if table is None or not isinstance(table, (list, tuple)): + return [] + + results: list[Any] = [] + search_value = str(value).lower() if value else "" + + for item in cast(list[Any], table): + item_value: Any = None + if isinstance(item, dict): + item_dict = cast(dict[str, Any], item) + item_value = item_dict.get(column, "") + elif hasattr(item, column): + item_value = getattr(item, column, "") + else: + continue + + # Case-insensitive contains search + if search_value in str(item_value).lower(): + results.append(item) + + return results + + +# Registry of custom functions +CUSTOM_FUNCTIONS: dict[str, Any] = { + "MessageText": message_text, + "UserMessage": user_message, + "AssistantMessage": assistant_message, + "SystemMessage": system_message, + "If": if_func, + "IsBlank": is_blank, + "Or": or_func, + "And": and_func, + "Not": not_func, + "CountRows": count_rows, + "First": first, + "Last": last, + "Find": find, + "Upper": upper, + "Lower": lower, + "Concat": concat_strings, + "Search": search_table, + "ForAll": for_all, +} diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_state.py b/python/packages/declarative/agent_framework_declarative/_workflows/_state.py new file mode 100644 index 0000000000..cb8fad5683 --- /dev/null +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_state.py @@ -0,0 +1,625 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""WorkflowState manages PowerFx variables during declarative workflow execution. + +This module provides state management for declarative workflows, handling: +- Workflow inputs (read-only) +- Turn-scoped variables +- Workflow outputs +- Agent results and context +""" + +from collections.abc import Mapping +from typing import Any, cast + +from agent_framework import get_logger + +try: + from powerfx import Engine + + _powerfx_engine: Engine | None = Engine() +except (ImportError, RuntimeError): + # ImportError: powerfx package not installed + # RuntimeError: .NET runtime not available or misconfigured + _powerfx_engine = None + +logger = get_logger("agent_framework.declarative.workflows") + + +class WorkflowState: + """Manages variables and state during declarative workflow execution. + + WorkflowState provides a unified interface for: + + - Reading workflow inputs (immutable after initialization) + - Managing turn-scoped variables that persist across actions + - Storing agent results and making them available to subsequent actions + - Evaluating PowerFx expressions with the current state as context + + The state is organized into namespaces that mirror the .NET implementation: + + - workflow.inputs: Initial inputs to the workflow + - workflow.outputs: Values to be returned from the workflow + - turn: Variables that persist within the current workflow turn + - agent: Results from the most recent agent invocation + - conversation: Conversation history and messages + + Examples: + .. code-block:: python + + from agent_framework_declarative import WorkflowState + + # Initialize with inputs + state = WorkflowState(inputs={"query": "Hello", "user_id": "123"}) + + # Access inputs (read-only) + query = state.get("workflow.inputs.query") # "Hello" + + # Set turn-scoped variables + state.set("turn.results", []) + state.append("turn.results", "item1") + state.append("turn.results", "item2") + + # Set workflow outputs + state.set("workflow.outputs.response", "Completed") + + .. code-block:: python + + from agent_framework_declarative import WorkflowState + + # PowerFx expression evaluation + state = WorkflowState(inputs={"name": "World"}) + result = state.eval("=Concat('Hello ', workflow.inputs.name)") + # result: "Hello World" + + # Non-PowerFx strings are returned as-is + plain = state.eval("Hello World") + # plain: "Hello World" + + .. code-block:: python + + from agent_framework_declarative import WorkflowState + + # Working with agent results + state = WorkflowState() + state.set_agent_result( + text="The answer is 42.", + messages=[], + tool_calls=[], + ) + + # Access agent result in subsequent actions + response = state.get("agent.text") # "The answer is 42." + """ + + def __init__( + self, + inputs: Mapping[str, Any] | None = None, + ) -> None: + """Initialize workflow state with optional inputs. + + Args: + inputs: Initial inputs to the workflow. These become available + as workflow.inputs.* and are immutable after initialization. + """ + self._inputs: dict[str, Any] = dict(inputs) if inputs else {} + self._turn: dict[str, Any] = {} + self._outputs: dict[str, Any] = {} + self._agent: dict[str, Any] = {} + self._conversation: dict[str, Any] = { + "messages": [], + "history": [], + } + self._custom: dict[str, Any] = {} + + @property + def inputs(self) -> Mapping[str, Any]: + """Get the workflow inputs (read-only).""" + return self._inputs + + @property + def outputs(self) -> dict[str, Any]: + """Get the workflow outputs.""" + return self._outputs + + @property + def turn(self) -> dict[str, Any]: + """Get the turn-scoped variables.""" + return self._turn + + @property + def agent(self) -> dict[str, Any]: + """Get the most recent agent result.""" + return self._agent + + @property + def conversation(self) -> dict[str, Any]: + """Get the conversation state.""" + return self._conversation + + def get(self, path: str, default: Any = None) -> Any: + """Get a value from the state using a dot-notated path. + + Args: + path: Dot-notated path like 'turn.results' or 'workflow.inputs.query' + default: Default value if path doesn't exist + + Returns: + The value at the path, or default if not found + """ + parts = path.split(".") + if not parts: + return default + + # Determine the namespace (case-insensitive to match .NET) + namespace = parts[0].lower() + remaining = parts[1:] + + # Handle workflow.inputs and workflow.outputs specially + if namespace == "workflow" and remaining: + sub_namespace = remaining[0].lower() + remaining = remaining[1:] + if sub_namespace == "inputs": + obj: Any = self._inputs + elif sub_namespace == "outputs": + obj = self._outputs + else: + return default + elif namespace == "turn": + obj = self._turn + elif namespace == "agent": + obj = self._agent + elif namespace == "conversation": + obj = self._conversation + else: + # Try custom namespace + obj = self._custom.get(namespace, default) + if obj is default: + return default + + # Navigate the remaining path + for part in remaining: + if isinstance(obj, dict): + obj_dict: dict[str, Any] = cast(dict[str, Any], obj) + obj = obj_dict.get(part, default) + if obj is default: + return default + elif hasattr(obj, part): + obj = getattr(obj, part) + else: + return default + + return obj + + def set(self, path: str, value: Any) -> None: + """Set a value in the state using a dot-notated path. + + Args: + path: Dot-notated path like 'turn.results' or 'workflow.outputs.response' + value: The value to set + + Raises: + ValueError: If attempting to set workflow.inputs (which is read-only) + """ + parts = path.split(".") + if not parts: + return + + # Normalize namespace to lowercase for case-insensitive matching + namespace = parts[0].lower() + remaining = parts[1:] + + # Handle workflow.inputs and workflow.outputs specially + if namespace == "workflow": + if not remaining: + raise ValueError("Cannot set 'workflow' directly; use 'workflow.outputs.*'") + sub_namespace = remaining[0].lower() + remaining = remaining[1:] + if sub_namespace == "inputs": + raise ValueError("Cannot modify workflow.inputs - they are read-only") + if sub_namespace == "outputs": + target = self._outputs + else: + raise ValueError(f"Unknown workflow namespace: {sub_namespace}") + elif namespace == "turn": + target = self._turn + elif namespace == "agent": + target = self._agent + elif namespace == "conversation": + target = self._conversation + else: + # Create or use custom namespace (normalized to lowercase) + if namespace not in self._custom: + self._custom[namespace] = {} + target = self._custom[namespace] + + # Navigate to the parent and set the value + if not remaining: + # Setting the namespace root itself - this shouldn't happen normally + raise ValueError(f"Cannot replace entire namespace '{namespace}'") + + # Navigate to parent, creating dicts as needed + for part in remaining[:-1]: + if part not in target: + target[part] = {} + target = target[part] + + # Set the final value + target[remaining[-1]] = value + + def append(self, path: str, value: Any) -> None: + """Append a value to a list at the specified path. + + If the path doesn't exist, creates a new list with the value. + If the path exists but isn't a list, raises ValueError. + + Args: + path: Dot-notated path to a list + value: The value to append + + Raises: + ValueError: If the existing value is not a list + """ + existing = self.get(path) + if existing is None: + self.set(path, [value]) + elif isinstance(existing, list): + existing.append(value) + self.set(path, existing) + else: + raise ValueError(f"Cannot append to non-list at path '{path}'") + + def set_agent_result( + self, + text: str | None = None, + messages: list[Any] | None = None, + tool_calls: list[Any] | None = None, + **kwargs: Any, + ) -> None: + """Set the result from the most recent agent invocation. + + This updates the 'agent' namespace with the agent's response, + making it available to subsequent actions via agent.text, agent.messages, etc. + + Args: + text: The text content of the agent's response + messages: The messages from the agent + tool_calls: Any tool calls made by the agent + **kwargs: Additional result data + """ + self._agent = { + "text": text, + "messages": messages or [], + "toolCalls": tool_calls or [], + **kwargs, + } + + def add_conversation_message(self, message: Any) -> None: + """Add a message to the conversation history. + + Args: + message: The message to add (typically a ChatMessage or similar) + """ + self._conversation["messages"].append(message) + self._conversation["history"].append(message) + + def to_powerfx_symbols(self) -> dict[str, Any]: + """Convert the current state to a PowerFx symbols dictionary. + + Returns: + A dictionary suitable for passing to PowerFx Engine.eval() + """ + return { + "workflow": { + "inputs": dict(self._inputs), + "outputs": dict(self._outputs), + }, + "turn": dict(self._turn), + "agent": dict(self._agent), + "conversation": dict(self._conversation), + **self._custom, + } + + def eval(self, expression: str) -> Any: + """Evaluate a PowerFx expression with the current state. + + Expressions starting with '=' are evaluated as PowerFx. + Other strings are returned as-is (after variable interpolation if applicable). + + Args: + expression: The expression to evaluate + + Returns: + The evaluated result, or the original expression if not a PowerFx expression + """ + if not expression: + return expression + + if not expression.startswith("="): + return expression + + # Strip the leading '=' for evaluation + formula = expression[1:] + + if _powerfx_engine is not None: + # Try PowerFx evaluation first + try: + symbols = self.to_powerfx_symbols() + return _powerfx_engine.eval(formula, symbols=symbols) + except Exception as exc: + logger.warning(f"PowerFx evaluation failed for '{expression[:50]}': {exc}") + # Fall through to simple evaluation + + # Fallback: Simple expression evaluation using custom functions + return self._eval_simple(formula) + + def _eval_simple(self, formula: str) -> Any: + """Simple expression evaluation when PowerFx is not available. + + Supports: + - Variable references: Local.X, System.X, turn.x + - Simple function calls: IsBlank(x), Find(a, b), etc. + - Simple comparisons: x < 4, x = "value" + - Logical operators: And, Or, Not, ||, ! + - Negation: !expression + + Args: + formula: The formula to evaluate (without leading '=') + + Returns: + The evaluated result + """ + from ._powerfx_functions import CUSTOM_FUNCTIONS + + formula = formula.strip() + + # Handle negation prefix + if formula.startswith("!"): + inner = formula[1:].strip() + result = self._eval_simple(inner) + return not bool(result) + + # Handle Not() function + if formula.startswith("Not(") and formula.endswith(")"): + inner = formula[4:-1].strip() + result = self._eval_simple(inner) + return not bool(result) + + # Handle function calls + for func_name, func in CUSTOM_FUNCTIONS.items(): + if formula.startswith(f"{func_name}(") and formula.endswith(")"): + args_str = formula[len(func_name) + 1 : -1] + # Simple argument parsing (doesn't handle nested calls well) + args = self._parse_function_args(args_str) + evaluated_args = [self._eval_simple(arg) if isinstance(arg, str) else arg for arg in args] + try: + return func(*evaluated_args) + except Exception as e: + logger.warning(f"Function {func_name} failed: {e}") + return formula + + # Handle And operator + if " And " in formula: + parts = formula.split(" And ", 1) + left = self._eval_simple(parts[0]) + right = self._eval_simple(parts[1]) + return bool(left) and bool(right) + + # Handle Or operator (||) + if " || " in formula or " Or " in formula: + parts = formula.split(" || ", 1) if " || " in formula else formula.split(" Or ", 1) + left = self._eval_simple(parts[0]) + right = self._eval_simple(parts[1]) + return bool(left) or bool(right) + + # Handle comparison operators + for op in [" < ", " > ", " <= ", " >= ", " <> ", " = "]: + if op in formula: + parts = formula.split(op, 1) + left = self._eval_simple(parts[0].strip()) + right = self._eval_simple(parts[1].strip()) + if op == " < ": + return left < right + if op == " > ": + return left > right + if op == " <= ": + return left <= right + if op == " >= ": + return left >= right + if op == " <> ": + return left != right + if op == " = ": + return left == right + + # Handle arithmetic operators + if " + " in formula: + parts = formula.split(" + ", 1) + left = self._eval_simple(parts[0].strip()) + right = self._eval_simple(parts[1].strip()) + # Treat None as 0 for arithmetic (PowerFx behavior) + if left is None: + left = 0 + if right is None: + right = 0 + # Try numeric addition first, fall back to string concat + try: + return float(left) + float(right) + except (ValueError, TypeError): + return str(left) + str(right) + + if " - " in formula: + parts = formula.split(" - ", 1) + left = self._eval_simple(parts[0].strip()) + right = self._eval_simple(parts[1].strip()) + # Treat None as 0 for arithmetic (PowerFx behavior) + if left is None: + left = 0 + if right is None: + right = 0 + try: + return float(left) - float(right) + except (ValueError, TypeError): + return formula + + # Handle multiplication + if " * " in formula: + parts = formula.split(" * ", 1) + left = self._eval_simple(parts[0].strip()) + right = self._eval_simple(parts[1].strip()) + # Treat None as 0 for arithmetic (PowerFx behavior) + if left is None: + left = 0 + if right is None: + right = 0 + try: + return float(left) * float(right) + except (ValueError, TypeError): + return formula + + # Handle division with div-by-zero protection + if " / " in formula: + parts = formula.split(" / ", 1) + left = self._eval_simple(parts[0].strip()) + right = self._eval_simple(parts[1].strip()) + # Treat None as 0 for arithmetic (PowerFx behavior) + if left is None: + left = 0 + if right is None: + right = 0 + try: + right_float = float(right) + if right_float == 0: + # PowerFx returns Error for division by zero; we return None (Blank) + logger.warning(f"Division by zero in expression: {formula}") + return None + return float(left) / right_float + except (ValueError, TypeError): + return formula + + # Handle string literals + if (formula.startswith('"') and formula.endswith('"')) or (formula.startswith("'") and formula.endswith("'")): + return formula[1:-1] + + # Handle numeric literals + try: + if "." in formula: + return float(formula) + return int(formula) + except ValueError: + pass + + # Handle boolean literals + if formula.lower() == "true": + return True + if formula.lower() == "false": + return False + + # Handle variable references + if "." in formula: + # Map .NET style to Python style + path = formula + if formula.startswith("Local."): + path = "turn." + formula[6:] + elif formula.startswith("System."): + path = "system." + formula[7:] + elif formula.startswith("inputs."): + path = "workflow.inputs." + formula[7:] + # For known namespaces, return None if not found (PowerFx semantics) + # rather than the formula string + if path.startswith(("turn.", "workflow.", "agent.", "conversation.", "system.")): + return self.get(path) + not_found = object() + value = self.get(path, default=not_found) + if value is not not_found: + return value + + # Return the formula as-is if we can't evaluate it + return formula + + def _parse_function_args(self, args_str: str) -> list[str]: + """Parse function arguments, handling nested parentheses and strings. + + Args: + args_str: The argument string (without outer parentheses) + + Returns: + List of argument strings + """ + args: list[str] = [] + current = "" + depth = 0 + in_string = False + string_char = None + + for char in args_str: + if char in ('"', "'") and not in_string: + in_string = True + string_char = char + current += char + elif char == string_char and in_string: + in_string = False + string_char = None + current += char + elif char == "(" and not in_string: + depth += 1 + current += char + elif char == ")" and not in_string: + depth -= 1 + current += char + elif char == "," and depth == 0 and not in_string: + args.append(current.strip()) + current = "" + else: + current += char + + if current.strip(): + args.append(current.strip()) + + return args + + def eval_if_expression(self, value: Any) -> Any: + """Evaluate a value if it's a PowerFx expression, otherwise return as-is. + + This is a convenience method that handles both expressions and literals. + + Args: + value: A value that may or may not be a PowerFx expression + + Returns: + The evaluated result if it's an expression, or the original value + """ + if isinstance(value, str): + return self.eval(value) + if isinstance(value, dict): + return {str(k): self.eval_if_expression(v) for k, v in value.items()} + if isinstance(value, list): + return [self.eval_if_expression(item) for item in value] + return value + + def reset_turn(self) -> None: + """Reset turn-scoped variables for a new turn. + + This clears the turn namespace while preserving other state. + """ + self._turn.clear() + + def reset_agent(self) -> None: + """Reset the agent result for a new agent invocation.""" + self._agent.clear() + + def clone(self) -> "WorkflowState": + """Create a shallow copy of the state. + + Returns: + A new WorkflowState with copied data + """ + import copy + + new_state = WorkflowState() + new_state._inputs = copy.copy(self._inputs) + new_state._turn = copy.copy(self._turn) + new_state._outputs = copy.copy(self._outputs) + new_state._agent = copy.copy(self._agent) + new_state._conversation = copy.copy(self._conversation) + new_state._custom = copy.copy(self._custom) + return new_state diff --git a/python/packages/declarative/tests/test_additional_handlers.py b/python/packages/declarative/tests/test_additional_handlers.py new file mode 100644 index 0000000000..1a83cf039f --- /dev/null +++ b/python/packages/declarative/tests/test_additional_handlers.py @@ -0,0 +1,348 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for additional action handlers (conversation, variables, etc.).""" + +import pytest + +import agent_framework_declarative._workflows._actions_basic # noqa: F401 +import agent_framework_declarative._workflows._actions_control_flow # noqa: F401 +from agent_framework_declarative._workflows._handlers import get_action_handler +from agent_framework_declarative._workflows._state import WorkflowState + + +def create_action_context(action: dict, state: WorkflowState | None = None): + """Create a minimal action context for testing.""" + from agent_framework_declarative._workflows._handlers import ActionContext + + if state is None: + state = WorkflowState() + + async def execute_actions(actions, state): + for act in actions: + handler = get_action_handler(act.get("kind")) + if handler: + async for event in handler( + ActionContext( + state=state, + action=act, + execute_actions=execute_actions, + agents={}, + bindings={}, + ) + ): + yield event + + return ActionContext( + state=state, + action=action, + execute_actions=execute_actions, + agents={}, + bindings={}, + ) + + +class TestSetTextVariableHandler: + """Tests for SetTextVariable action handler.""" + + @pytest.mark.asyncio + async def test_set_text_variable_simple(self): + """Test setting a simple text variable.""" + ctx = create_action_context({ + "kind": "SetTextVariable", + "variable": "Local.greeting", + "value": "Hello, World!", + }) + + handler = get_action_handler("SetTextVariable") + _events = [e async for e in handler(ctx)] # noqa: F841 + + assert ctx.state.get("turn.greeting") == "Hello, World!" + + @pytest.mark.asyncio + async def test_set_text_variable_with_interpolation(self): + """Test setting text with variable interpolation.""" + state = WorkflowState() + state.set("turn.name", "Alice") + + ctx = create_action_context( + { + "kind": "SetTextVariable", + "variable": "Local.message", + "value": "Hello, {Local.name}!", + }, + state=state, + ) + + handler = get_action_handler("SetTextVariable") + _events = [e async for e in handler(ctx)] # noqa: F841 + + assert ctx.state.get("turn.message") == "Hello, Alice!" + + +class TestResetVariableHandler: + """Tests for ResetVariable action handler.""" + + @pytest.mark.asyncio + async def test_reset_variable(self): + """Test resetting a variable to None.""" + state = WorkflowState() + state.set("turn.counter", 5) + + ctx = create_action_context( + { + "kind": "ResetVariable", + "variable": "Local.counter", + }, + state=state, + ) + + handler = get_action_handler("ResetVariable") + _events = [e async for e in handler(ctx)] # noqa: F841 + + assert ctx.state.get("turn.counter") is None + + +class TestSetMultipleVariablesHandler: + """Tests for SetMultipleVariables action handler.""" + + @pytest.mark.asyncio + async def test_set_multiple_variables(self): + """Test setting multiple variables at once.""" + ctx = create_action_context({ + "kind": "SetMultipleVariables", + "variables": [ + {"variable": "Local.a", "value": 1}, + {"variable": "Local.b", "value": 2}, + {"variable": "Local.c", "value": "three"}, + ], + }) + + handler = get_action_handler("SetMultipleVariables") + _events = [e async for e in handler(ctx)] # noqa: F841 + + assert ctx.state.get("turn.a") == 1 + assert ctx.state.get("turn.b") == 2 + assert ctx.state.get("turn.c") == "three" + + +class TestClearAllVariablesHandler: + """Tests for ClearAllVariables action handler.""" + + @pytest.mark.asyncio + async def test_clear_all_variables(self): + """Test clearing all turn-scoped variables.""" + state = WorkflowState() + state.set("turn.a", 1) + state.set("turn.b", 2) + state.set("workflow.outputs.result", "kept") + + ctx = create_action_context( + { + "kind": "ClearAllVariables", + }, + state=state, + ) + + handler = get_action_handler("ClearAllVariables") + _events = [e async for e in handler(ctx)] # noqa: F841 + + assert ctx.state.get("turn.a") is None + assert ctx.state.get("turn.b") is None + # Workflow outputs should be preserved + assert ctx.state.get("workflow.outputs.result") == "kept" + + +class TestCreateConversationHandler: + """Tests for CreateConversation action handler.""" + + @pytest.mark.asyncio + async def test_create_conversation_with_output_binding(self): + """Test creating a new conversation with output variable binding. + + The conversationId field specifies the OUTPUT variable where the + auto-generated conversation ID is stored. + """ + ctx = create_action_context({ + "kind": "CreateConversation", + "conversationId": "Local.myConvId", # Output variable + }) + + handler = get_action_handler("CreateConversation") + _events = [e async for e in handler(ctx)] # noqa: F841 + + # Check conversation was created with auto-generated ID + conversations = ctx.state.get("system.conversations") + assert conversations is not None + assert len(conversations) == 1 + + # Get the generated ID + generated_id = list(conversations.keys())[0] + assert conversations[generated_id]["messages"] == [] + + # Check output binding - the ID should be stored in the specified variable + assert ctx.state.get("turn.myConvId") == generated_id + + @pytest.mark.asyncio + async def test_create_conversation_legacy_output(self): + """Test creating a conversation with legacy output binding.""" + ctx = create_action_context({ + "kind": "CreateConversation", + "output": { + "conversationId": "Local.myConvId", + }, + }) + + handler = get_action_handler("CreateConversation") + _events = [e async for e in handler(ctx)] # noqa: F841 + + # Check conversation was created + conversations = ctx.state.get("system.conversations") + assert conversations is not None + assert len(conversations) == 1 + + # Get the generated ID + generated_id = list(conversations.keys())[0] + + # Check legacy output binding + assert ctx.state.get("turn.myConvId") == generated_id + + @pytest.mark.asyncio + async def test_create_conversation_auto_id(self): + """Test creating a conversation with auto-generated ID.""" + ctx = create_action_context({ + "kind": "CreateConversation", + }) + + handler = get_action_handler("CreateConversation") + _events = [e async for e in handler(ctx)] # noqa: F841 + + # Check conversation was created with some ID + conversations = ctx.state.get("system.conversations") + assert conversations is not None + assert len(conversations) == 1 + + +class TestAddConversationMessageHandler: + """Tests for AddConversationMessage action handler.""" + + @pytest.mark.asyncio + async def test_add_conversation_message(self): + """Test adding a message to a conversation.""" + state = WorkflowState() + state.set( + "system.conversations", + { + "conv-123": {"id": "conv-123", "messages": []}, + }, + ) + + ctx = create_action_context( + { + "kind": "AddConversationMessage", + "conversationId": "conv-123", + "message": { + "role": "user", + "content": "Hello!", + }, + }, + state=state, + ) + + handler = get_action_handler("AddConversationMessage") + _events = [e async for e in handler(ctx)] # noqa: F841 + + conversations = ctx.state.get("system.conversations") + assert len(conversations["conv-123"]["messages"]) == 1 + assert conversations["conv-123"]["messages"][0]["content"] == "Hello!" + + +class TestEndWorkflowHandler: + """Tests for EndWorkflow action handler.""" + + @pytest.mark.asyncio + async def test_end_workflow_signal(self): + """Test that EndWorkflow emits correct signal.""" + from agent_framework_declarative._workflows._actions_control_flow import EndWorkflowSignal + + ctx = create_action_context({ + "kind": "EndWorkflow", + "reason": "Completed successfully", + }) + + handler = get_action_handler("EndWorkflow") + events = [e async for e in handler(ctx)] + + assert len(events) == 1 + assert isinstance(events[0], EndWorkflowSignal) + assert events[0].reason == "Completed successfully" + + +class TestEndConversationHandler: + """Tests for EndConversation action handler.""" + + @pytest.mark.asyncio + async def test_end_conversation_signal(self): + """Test that EndConversation emits correct signal.""" + from agent_framework_declarative._workflows._actions_control_flow import EndConversationSignal + + ctx = create_action_context({ + "kind": "EndConversation", + "conversationId": "conv-123", + }) + + handler = get_action_handler("EndConversation") + events = [e async for e in handler(ctx)] + + assert len(events) == 1 + assert isinstance(events[0], EndConversationSignal) + assert events[0].conversation_id == "conv-123" + + +class TestConditionGroupWithElseActions: + """Tests for ConditionGroup with elseActions.""" + + @pytest.mark.asyncio + async def test_condition_group_else_actions(self): + """Test that elseActions execute when no condition matches.""" + ctx = create_action_context({ + "kind": "ConditionGroup", + "conditions": [ + { + "condition": False, + "actions": [ + {"kind": "SetValue", "path": "turn.result", "value": "matched"}, + ], + }, + ], + "elseActions": [ + {"kind": "SetValue", "path": "turn.result", "value": "else"}, + ], + }) + + handler = get_action_handler("ConditionGroup") + _events = [e async for e in handler(ctx)] # noqa: F841 + + assert ctx.state.get("turn.result") == "else" + + @pytest.mark.asyncio + async def test_condition_group_match_skips_else(self): + """Test that elseActions don't execute when a condition matches.""" + ctx = create_action_context({ + "kind": "ConditionGroup", + "conditions": [ + { + "condition": True, + "actions": [ + {"kind": "SetValue", "path": "turn.result", "value": "matched"}, + ], + }, + ], + "elseActions": [ + {"kind": "SetValue", "path": "turn.result", "value": "else"}, + ], + }) + + handler = get_action_handler("ConditionGroup") + _events = [e async for e in handler(ctx)] # noqa: F841 + + assert ctx.state.get("turn.result") == "matched" diff --git a/python/packages/declarative/tests/test_declarative_loader.py b/python/packages/declarative/tests/test_declarative_loader.py index daf4ab06f8..3af7a3735f 100644 --- a/python/packages/declarative/tests/test_declarative_loader.py +++ b/python/packages/declarative/tests/test_declarative_loader.py @@ -454,3 +454,95 @@ def test_agent_schema_dispatch_agent_samples(yaml_file: Path, agent_samples_dir: result = agent_schema_dispatch(yaml.safe_load(content)) # Result can be None for unknown kinds, but should not raise exceptions assert result is not None, f"agent_schema_dispatch returned None for {yaml_file.relative_to(agent_samples_dir)}" + + +class TestAgentFactoryCreateFromDict: + """Tests for AgentFactory.create_agent_from_dict method.""" + + def test_create_agent_from_dict_parses_prompt_agent(self): + """Test that create_agent_from_dict correctly parses a PromptAgent definition.""" + from unittest.mock import MagicMock + + from agent_framework_declarative import AgentFactory + + agent_def = { + "kind": "Prompt", + "name": "TestAgent", + "description": "A test agent", + "instructions": "You are a helpful assistant.", + } + + # Use a pre-configured chat client to avoid needing model + mock_client = MagicMock() + mock_client.create_agent.return_value = MagicMock() + + factory = AgentFactory(chat_client=mock_client) + agent = factory.create_agent_from_dict(agent_def) + + assert agent is not None + + def test_create_agent_from_dict_matches_yaml(self): + """Test that create_agent_from_dict produces same result as create_agent_from_yaml.""" + from unittest.mock import MagicMock + + from agent_framework_declarative import AgentFactory + + yaml_content = """ +kind: Prompt +name: TestAgent +description: A test agent +instructions: You are a helpful assistant. +""" + + agent_def = { + "kind": "Prompt", + "name": "TestAgent", + "description": "A test agent", + "instructions": "You are a helpful assistant.", + } + + # Use a pre-configured chat client to avoid needing model + mock_client = MagicMock() + mock_client.create_agent.return_value = MagicMock() + + factory = AgentFactory(chat_client=mock_client) + + # Create from YAML string + agent_from_yaml = factory.create_agent_from_yaml(yaml_content) + + # Create from dict + agent_from_dict = factory.create_agent_from_dict(agent_def) + + # Both should produce agents with same name + assert agent_from_yaml.name == agent_from_dict.name + assert agent_from_yaml.description == agent_from_dict.description + + def test_create_agent_from_dict_invalid_kind_raises(self): + """Test that non-PromptAgent kind raises DeclarativeLoaderError.""" + from agent_framework_declarative import AgentFactory + from agent_framework_declarative._loader import DeclarativeLoaderError + + # Resource kind (not PromptAgent) + agent_def = { + "kind": "Resource", + "name": "TestResource", + } + + factory = AgentFactory() + with pytest.raises(DeclarativeLoaderError, match="Only definitions for a PromptAgent are supported"): + factory.create_agent_from_dict(agent_def) + + def test_create_agent_from_dict_without_model_or_client_raises(self): + """Test that missing both model and chat_client raises DeclarativeLoaderError.""" + from agent_framework_declarative import AgentFactory + from agent_framework_declarative._loader import DeclarativeLoaderError + + agent_def = { + "kind": "Prompt", + "name": "TestAgent", + "instructions": "You are helpful.", + } + + factory = AgentFactory() + with pytest.raises(DeclarativeLoaderError, match="ChatClient must be provided"): + factory.create_agent_from_dict(agent_def) diff --git a/python/packages/declarative/tests/test_declarative_models.py b/python/packages/declarative/tests/test_declarative_models.py index dc13b3a642..925b55d534 100644 --- a/python/packages/declarative/tests/test_declarative_models.py +++ b/python/packages/declarative/tests/test_declarative_models.py @@ -838,6 +838,16 @@ def test_environment_variable_from_dict(self): assert env_var.value == "secret123" +# Check if PowerFx is available +try: + from powerfx import Engine as _PfxEngine + + _PfxEngine() + _powerfx_available = True +except (ImportError, RuntimeError): + _powerfx_available = False + + class TestTryPowerfxEval: """Tests for _try_powerfx_eval function.""" @@ -855,6 +865,7 @@ def test_empty_string_returns_empty(self): """Test that empty strings are returned as empty.""" assert _try_powerfx_eval("") == "" + @pytest.mark.skipif(not _powerfx_available, reason="PowerFx engine not available") def test_simple_powerfx_expressions(self): """Test simple PowerFx expressions.""" from decimal import Decimal @@ -867,6 +878,7 @@ def test_simple_powerfx_expressions(self): assert _try_powerfx_eval('="hello"') == "hello" assert _try_powerfx_eval('="test value"') == "test value" + @pytest.mark.skipif(not _powerfx_available, reason="PowerFx engine not available") def test_env_variable_access(self, monkeypatch): """Test accessing environment variables using =Env. pattern.""" # Set up test environment variables @@ -879,6 +891,7 @@ def test_env_variable_access(self, monkeypatch): assert _try_powerfx_eval("=Env.API_KEY") == "secret123" assert _try_powerfx_eval("=Env.PORT") == "8080" + @pytest.mark.skipif(not _powerfx_available, reason="PowerFx engine not available") def test_env_variable_with_string_concatenation(self, monkeypatch): """Test env variables with string concatenation operator.""" monkeypatch.setenv("BASE_URL", "https://api.example.com") @@ -892,6 +905,7 @@ def test_env_variable_with_string_concatenation(self, monkeypatch): result = _try_powerfx_eval('="API Key: " & Env.API_VERSION') assert result == "API Key: v1" + @pytest.mark.skipif(not _powerfx_available, reason="PowerFx engine not available") def test_string_comparison_operators(self, monkeypatch): """Test PowerFx string comparison operators.""" monkeypatch.setenv("ENV_MODE", "production") @@ -904,6 +918,7 @@ def test_string_comparison_operators(self, monkeypatch): assert _try_powerfx_eval('=Env.ENV_MODE <> "development"') is True assert _try_powerfx_eval('=Env.ENV_MODE <> "production"') is False + @pytest.mark.skipif(not _powerfx_available, reason="PowerFx engine not available") def test_string_in_operator(self): """Test PowerFx 'in' operator for substring testing (case-insensitive).""" # Substring test - case insensitive - returns bool @@ -911,6 +926,7 @@ def test_string_in_operator(self): assert _try_powerfx_eval('="THE" in "The keyboard and the monitor"') is True assert _try_powerfx_eval('="xyz" in "The keyboard and the monitor"') is False + @pytest.mark.skipif(not _powerfx_available, reason="PowerFx engine not available") def test_string_exactin_operator(self): """Test PowerFx 'exactin' operator for substring testing (case-sensitive).""" # Substring test - case sensitive - returns bool @@ -918,6 +934,7 @@ def test_string_exactin_operator(self): assert _try_powerfx_eval('="windows" exactin "To display windows in the Windows operating system"') is True assert _try_powerfx_eval('="WINDOWS" exactin "To display windows in the Windows operating system"') is False + @pytest.mark.skipif(not _powerfx_available, reason="PowerFx engine not available") def test_logical_operators_with_strings(self): """Test PowerFx logical operators (And, Or, Not) with string comparisons.""" # And operator - returns bool @@ -941,6 +958,7 @@ def test_logical_operators_with_strings(self): # ! operator (alternative syntax) - returns bool assert _try_powerfx_eval('=!("a" = "b")') is True + @pytest.mark.skipif(not _powerfx_available, reason="PowerFx engine not available") def test_parentheses_for_precedence(self): """Test using parentheses to control operator precedence.""" from decimal import Decimal @@ -953,6 +971,7 @@ def test_parentheses_for_precedence(self): result = _try_powerfx_eval('=("a" = "a" Or "b" = "c") And "d" = "d"') assert result is True + @pytest.mark.skipif(not _powerfx_available, reason="PowerFx engine not available") def test_env_with_special_characters(self, monkeypatch): """Test env variables containing special characters in values.""" monkeypatch.setenv("URL_WITH_QUERY", "https://example.com?param=value") diff --git a/python/packages/declarative/tests/test_external_input.py b/python/packages/declarative/tests/test_external_input.py new file mode 100644 index 0000000000..db099e6d63 --- /dev/null +++ b/python/packages/declarative/tests/test_external_input.py @@ -0,0 +1,286 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for human-in-the-loop action handlers.""" + +import pytest + +from agent_framework_declarative._workflows._handlers import ActionContext, get_action_handler +from agent_framework_declarative._workflows._human_input import ( + QuestionRequest, + process_external_loop, + validate_input_response, +) +from agent_framework_declarative._workflows._state import WorkflowState + + +def create_action_context(action: dict, state: WorkflowState | None = None): + """Create a minimal action context for testing.""" + if state is None: + state = WorkflowState() + + async def execute_actions(actions, state): + for act in actions: + handler = get_action_handler(act.get("kind")) + if handler: + async for event in handler( + ActionContext( + state=state, + action=act, + execute_actions=execute_actions, + agents={}, + bindings={}, + ) + ): + yield event + + return ActionContext( + state=state, + action=action, + execute_actions=execute_actions, + agents={}, + bindings={}, + ) + + +class TestQuestionHandler: + """Tests for Question action handler.""" + + @pytest.mark.asyncio + async def test_question_emits_request_info_event(self): + """Test that Question handler emits QuestionRequest.""" + ctx = create_action_context({ + "kind": "Question", + "id": "ask_name", + "variable": "Local.userName", + "prompt": "What is your name?", + }) + + handler = get_action_handler("Question") + events = [e async for e in handler(ctx)] + + assert len(events) == 1 + assert isinstance(events[0], QuestionRequest) + assert events[0].request_id == "ask_name" + assert events[0].prompt == "What is your name?" + assert events[0].variable == "Local.userName" + + @pytest.mark.asyncio + async def test_question_with_choices(self): + """Test Question with multiple choice options.""" + ctx = create_action_context({ + "kind": "Question", + "id": "ask_choice", + "variable": "Local.selection", + "prompt": "Select an option:", + "choices": ["Option A", "Option B", "Option C"], + "default": "Option A", + }) + + handler = get_action_handler("Question") + events = [e async for e in handler(ctx)] + + assert len(events) == 1 + event = events[0] + assert isinstance(event, QuestionRequest) + assert event.choices == ["Option A", "Option B", "Option C"] + assert event.default_value == "Option A" + + @pytest.mark.asyncio + async def test_question_with_validation(self): + """Test Question with validation rules.""" + ctx = create_action_context({ + "kind": "Question", + "id": "ask_email", + "variable": "Local.email", + "prompt": "Enter your email:", + "validation": { + "required": True, + "pattern": r"^[\w\.-]+@[\w\.-]+\.\w+$", + }, + }) + + handler = get_action_handler("Question") + events = [e async for e in handler(ctx)] + + assert len(events) == 1 + event = events[0] + assert event.validation == { + "required": True, + "pattern": r"^[\w\.-]+@[\w\.-]+\.\w+$", + } + + +class TestRequestExternalInputHandler: + """Tests for RequestExternalInput action handler.""" + + @pytest.mark.asyncio + async def test_request_external_input(self): + """Test RequestExternalInput handler emits event.""" + ctx = create_action_context({ + "kind": "RequestExternalInput", + "id": "get_approval", + "variable": "Local.approval", + "prompt": "Please approve or reject", + "timeout": 300, + }) + + handler = get_action_handler("RequestExternalInput") + events = [e async for e in handler(ctx)] + + assert len(events) == 1 + event = events[0] + assert isinstance(event, QuestionRequest) + assert event.request_id == "get_approval" + assert event.variable == "Local.approval" + assert event.validation == {"timeout": 300} + + +class TestWaitForInputHandler: + """Tests for WaitForInput action handler.""" + + @pytest.mark.asyncio + async def test_wait_for_input(self): + """Test WaitForInput handler.""" + ctx = create_action_context({ + "kind": "WaitForInput", + "id": "wait", + "variable": "Local.response", + "message": "Waiting...", + }) + + handler = get_action_handler("WaitForInput") + events = [e async for e in handler(ctx)] + + assert len(events) == 1 + event = events[0] + assert isinstance(event, QuestionRequest) + assert event.request_id == "wait" + assert event.prompt == "Waiting..." + + +class TestProcessExternalLoop: + """Tests for process_external_loop helper function.""" + + def test_no_external_loop(self): + """Test when no external loop is configured.""" + state = WorkflowState() + result, expr = process_external_loop({}, state) + + assert result is False + assert expr is None + + def test_external_loop_true_condition(self): + """Test when external loop condition evaluates to true.""" + state = WorkflowState() + state.set("turn.isComplete", False) + + input_config = { + "externalLoop": { + "when": "=!Local.isComplete", + }, + } + + result, expr = process_external_loop(input_config, state) + + # !False = True, so loop should continue + assert result is True + assert expr == "=!Local.isComplete" + + def test_external_loop_false_condition(self): + """Test when external loop condition evaluates to false.""" + state = WorkflowState() + state.set("turn.isComplete", True) + + input_config = { + "externalLoop": { + "when": "=!Local.isComplete", + }, + } + + result, expr = process_external_loop(input_config, state) + + # !True = False, so loop should stop + assert result is False + + +class TestValidateInputResponse: + """Tests for validate_input_response helper function.""" + + def test_no_validation(self): + """Test with no validation rules.""" + is_valid, error = validate_input_response("any value", None) + assert is_valid is True + assert error is None + + def test_required_valid(self): + """Test required validation with valid value.""" + is_valid, error = validate_input_response("value", {"required": True}) + assert is_valid is True + assert error is None + + def test_required_empty_string(self): + """Test required validation with empty string.""" + is_valid, error = validate_input_response("", {"required": True}) + assert is_valid is False + assert "required" in error.lower() + + def test_required_none(self): + """Test required validation with None.""" + is_valid, error = validate_input_response(None, {"required": True}) + assert is_valid is False + assert "required" in error.lower() + + def test_min_length_valid(self): + """Test minLength validation with valid value.""" + is_valid, error = validate_input_response("hello", {"minLength": 3}) + assert is_valid is True + + def test_min_length_invalid(self): + """Test minLength validation with too short value.""" + is_valid, error = validate_input_response("hi", {"minLength": 3}) + assert is_valid is False + assert "minimum length" in error.lower() + + def test_max_length_valid(self): + """Test maxLength validation with valid value.""" + is_valid, error = validate_input_response("hello", {"maxLength": 10}) + assert is_valid is True + + def test_max_length_invalid(self): + """Test maxLength validation with too long value.""" + is_valid, error = validate_input_response("hello world", {"maxLength": 5}) + assert is_valid is False + assert "maximum length" in error.lower() + + def test_min_value_valid(self): + """Test min validation for numbers.""" + is_valid, error = validate_input_response(10, {"min": 5}) + assert is_valid is True + + def test_min_value_invalid(self): + """Test min validation with too small number.""" + is_valid, error = validate_input_response(3, {"min": 5}) + assert is_valid is False + assert "minimum value" in error.lower() + + def test_max_value_valid(self): + """Test max validation for numbers.""" + is_valid, error = validate_input_response(5, {"max": 10}) + assert is_valid is True + + def test_max_value_invalid(self): + """Test max validation with too large number.""" + is_valid, error = validate_input_response(15, {"max": 10}) + assert is_valid is False + assert "maximum value" in error.lower() + + def test_pattern_valid(self): + """Test pattern validation with matching value.""" + is_valid, error = validate_input_response("test@example.com", {"pattern": r"^[\w\.-]+@[\w\.-]+\.\w+$"}) + assert is_valid is True + + def test_pattern_invalid(self): + """Test pattern validation with non-matching value.""" + is_valid, error = validate_input_response("not-an-email", {"pattern": r"^[\w\.-]+@[\w\.-]+\.\w+$"}) + assert is_valid is False + assert "pattern" in error.lower() diff --git a/python/packages/declarative/tests/test_graph_coverage.py b/python/packages/declarative/tests/test_graph_coverage.py new file mode 100644 index 0000000000..6f341d7087 --- /dev/null +++ b/python/packages/declarative/tests/test_graph_coverage.py @@ -0,0 +1,2762 @@ +# Copyright (c) Microsoft. All rights reserved. +# pyright: reportUnknownParameterType=false, reportUnknownArgumentType=false +# pyright: reportMissingParameterType=false, reportUnknownMemberType=false +# pyright: reportPrivateUsage=false, reportUnknownVariableType=false +# pyright: reportGeneralTypeIssues=false + +from dataclasses import dataclass +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from agent_framework_declarative._workflows import ( + ActionComplete, + ActionTrigger, + DeclarativeWorkflowState, +) +from agent_framework_declarative._workflows._declarative_base import ( + ConditionResult, + LoopControl, + LoopIterationResult, +) + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_shared_state() -> MagicMock: + """Create a mock shared state with async get/set/delete methods.""" + shared_state = MagicMock() + shared_state._data = {} + + async def mock_get(key: str) -> Any: + if key not in shared_state._data: + raise KeyError(key) + return shared_state._data[key] + + async def mock_set(key: str, value: Any) -> None: + shared_state._data[key] = value + + async def mock_delete(key: str) -> None: + if key in shared_state._data: + del shared_state._data[key] + + shared_state.get = AsyncMock(side_effect=mock_get) + shared_state.set = AsyncMock(side_effect=mock_set) + shared_state.delete = AsyncMock(side_effect=mock_delete) + + return shared_state + + +@pytest.fixture +def mock_context(mock_shared_state: MagicMock) -> MagicMock: + """Create a mock workflow context.""" + ctx = MagicMock() + ctx.shared_state = mock_shared_state + ctx.send_message = AsyncMock() + ctx.yield_output = AsyncMock() + ctx.request_info = AsyncMock() + return ctx + + +# --------------------------------------------------------------------------- +# DeclarativeWorkflowState Tests - Covering _base.py gaps +# --------------------------------------------------------------------------- + + +class TestDeclarativeWorkflowStateExtended: + """Extended tests for DeclarativeWorkflowState covering uncovered code paths.""" + + async def test_get_with_local_namespace(self, mock_shared_state): + """Test Local. namespace mapping.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.myVar", "value123") + + # Access via Local. namespace + result = await state.get("Local.myVar") + assert result == "value123" + + async def test_get_with_system_namespace(self, mock_shared_state): + """Test System. namespace mapping.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("system.ConversationId", "conv-123") + + result = await state.get("System.ConversationId") + assert result == "conv-123" + + async def test_get_with_workflow_namespace(self, mock_shared_state): + """Test Workflow. namespace mapping.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize({"query": "test"}) + + result = await state.get("Workflow.inputs.query") + assert result == "test" + + async def test_get_with_inputs_shorthand(self, mock_shared_state): + """Test inputs. shorthand namespace mapping.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize({"query": "test"}) + + result = await state.get("inputs.query") + assert result == "test" + + async def test_get_agent_namespace(self, mock_shared_state): + """Test agent namespace access.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("agent.response", "Hello!") + + result = await state.get("agent.response") + assert result == "Hello!" + + async def test_get_conversation_namespace(self, mock_shared_state): + """Test conversation namespace access.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("conversation.messages", [{"role": "user", "text": "hi"}]) + + result = await state.get("conversation.messages") + assert result == [{"role": "user", "text": "hi"}] + + async def test_get_custom_namespace(self, mock_shared_state): + """Test custom namespace access.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + # Set via direct state data manipulation to create custom namespace + state_data = await state.get_state_data() + state_data["custom"] = {"myns": {"value": 42}} + await state.set_state_data(state_data) + + result = await state.get("myns.value") + assert result == 42 + + async def test_get_object_attribute_access(self, mock_shared_state): + """Test accessing object attributes via hasattr/getattr path.""" + + @dataclass + class MockObj: + name: str + value: int + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.obj", MockObj(name="test", value=99)) + + result = await state.get("turn.obj.name") + assert result == "test" + + async def test_set_with_local_namespace(self, mock_shared_state): + """Test Local. namespace mapping for set.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + await state.set("Local.myVar", "value123") + result = await state.get("turn.myVar") + assert result == "value123" + + async def test_set_with_system_namespace(self, mock_shared_state): + """Test System. namespace mapping for set.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + await state.set("System.ConversationId", "conv-456") + result = await state.get("system.ConversationId") + assert result == "conv-456" + + async def test_set_workflow_outputs(self, mock_shared_state): + """Test setting workflow outputs.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + await state.set("workflow.outputs.result", "done") + outputs = await state.get_outputs() + assert outputs.get("result") == "done" + + async def test_set_workflow_inputs_raises_error(self, mock_shared_state): + """Test that setting workflow.inputs raises an error (read-only).""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize({"query": "test"}) + + with pytest.raises(ValueError, match="Cannot modify workflow.inputs"): + await state.set("workflow.inputs.query", "modified") + + async def test_set_workflow_directly_raises_error(self, mock_shared_state): + """Test that setting 'workflow' directly raises an error.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + with pytest.raises(ValueError, match="Cannot set 'workflow' directly"): + await state.set("workflow", {}) + + async def test_set_unknown_workflow_subnamespace_raises_error(self, mock_shared_state): + """Test unknown workflow sub-namespace raises error.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + with pytest.raises(ValueError, match="Unknown workflow namespace"): + await state.set("workflow.unknown.field", "value") + + async def test_set_creates_custom_namespace(self, mock_shared_state): + """Test setting value in custom namespace creates it.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + await state.set("myns.field.nested", "value") + result = await state.get("myns.field.nested") + assert result == "value" + + async def test_set_cannot_replace_entire_namespace(self, mock_shared_state): + """Test that replacing entire namespace raises error.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + with pytest.raises(ValueError, match="Cannot replace entire namespace"): + await state.set("turn", {}) + + async def test_append_to_nonlist_raises_error(self, mock_shared_state): + """Test appending to non-list raises error.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.scalar", "string value") + + with pytest.raises(ValueError, match="Cannot append to non-list"): + await state.append("turn.scalar", "new item") + + async def test_eval_empty_string(self, mock_shared_state): + """Test evaluating empty string returns as-is.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + result = await state.eval("") + assert result == "" + + async def test_eval_non_string_returns_as_is(self, mock_shared_state): + """Test evaluating non-string returns as-is.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + # Cast to Any to test the runtime behavior with non-string inputs + result = await state.eval(42) # type: ignore[arg-type] + assert result == 42 + + result = await state.eval([1, 2, 3]) # type: ignore[arg-type] + assert result == [1, 2, 3] + + async def test_eval_simple_and_operator(self, mock_shared_state): + """Test simple And operator evaluation.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.a", True) + await state.set("turn.b", False) + + result = await state.eval("=turn.a And turn.b") + assert result is False + + await state.set("turn.b", True) + result = await state.eval("=turn.a And turn.b") + assert result is True + + async def test_eval_simple_or_operator(self, mock_shared_state): + """Test simple Or operator evaluation.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.a", True) + await state.set("turn.b", False) + + result = await state.eval("=turn.a Or turn.b") + assert result is True + + await state.set("turn.a", False) + result = await state.eval("=turn.a Or turn.b") + assert result is False + + async def test_eval_negation(self, mock_shared_state): + """Test negation (!) evaluation.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.flag", True) + + result = await state.eval("=!turn.flag") + assert result is False + + async def test_eval_not_function(self, mock_shared_state): + """Test Not() function evaluation.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.flag", True) + + result = await state.eval("=Not(turn.flag)") + assert result is False + + async def test_eval_comparison_operators(self, mock_shared_state): + """Test comparison operators.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.x", 5) + await state.set("turn.y", 10) + + assert await state.eval("=turn.x < turn.y") is True + assert await state.eval("=turn.x > turn.y") is False + assert await state.eval("=turn.x <= 5") is True + assert await state.eval("=turn.x >= 5") is True + assert await state.eval("=turn.x <> turn.y") is True + assert await state.eval("=turn.x != turn.y") is True + assert await state.eval("=turn.x = 5") is True + assert await state.eval("=turn.x == 5") is True + + async def test_eval_arithmetic_operators(self, mock_shared_state): + """Test arithmetic operators.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.x", 10) + await state.set("turn.y", 3) + + assert await state.eval("=turn.x + turn.y") == 13 + assert await state.eval("=turn.x - turn.y") == 7 + assert await state.eval("=turn.x * turn.y") == 30 + assert await state.eval("=turn.x / turn.y") == pytest.approx(3.333, rel=0.01) + + async def test_eval_arithmetic_with_none_as_zero(self, mock_shared_state): + """Test arithmetic treats None as 0.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.x", 5) + # turn.y is not set, so it's None + + result = await state.eval("=turn.x + turn.y") + assert result == 5 + + async def test_eval_string_literal(self, mock_shared_state): + """Test string literal evaluation.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + result = await state.eval('="hello world"') + assert result == "hello world" + + result = await state.eval("='single quotes'") + assert result == "single quotes" + + async def test_eval_float_literal(self, mock_shared_state): + """Test float literal evaluation.""" + from decimal import Decimal + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + result = await state.eval("=3.14") + # Accepts both float (Python fallback) and Decimal (pythonnet/PowerFx) + assert result == 3.14 or result == Decimal("3.14") + + async def test_eval_variable_reference_with_namespace_mappings(self, mock_shared_state): + """Test variable reference with various namespace mappings.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize({"query": "test"}) + await state.set("turn.myVar", "localValue") + await state.set("system.convId", "sys123") + + # Test Local. mapping + result = await state.eval("=Local.myVar") + assert result == "localValue" + + # Test System. mapping + result = await state.eval("=System.convId") + assert result == "sys123" + + # Test inputs. mapping + result = await state.eval("=inputs.query") + assert result == "test" + + async def test_eval_if_expression_with_dict(self, mock_shared_state): + """Test eval_if_expression recursively evaluates dicts.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.name", "Alice") + + result = await state.eval_if_expression({"greeting": "=turn.name", "static": "hello"}) + assert result == {"greeting": "Alice", "static": "hello"} + + async def test_eval_if_expression_with_list(self, mock_shared_state): + """Test eval_if_expression recursively evaluates lists.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.x", 10) + + result = await state.eval_if_expression(["=turn.x", "static", "=5"]) + assert result == [10, "static", 5] + + async def test_interpolate_string_with_local_vars(self, mock_shared_state): + """Test string interpolation with Local. variables.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.TicketId", "TKT-001") + await state.set("turn.TeamName", "Support") + + result = await state.interpolate_string("Created ticket #{Local.TicketId} for team {Local.TeamName}") + assert result == "Created ticket #TKT-001 for team Support" + + async def test_interpolate_string_with_system_vars(self, mock_shared_state): + """Test string interpolation with System. variables.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("system.ConversationId", "conv-789") + + result = await state.interpolate_string("Conversation: {System.ConversationId}") + assert result == "Conversation: conv-789" + + async def test_interpolate_string_with_none_value(self, mock_shared_state): + """Test string interpolation with None value returns empty string.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + result = await state.interpolate_string("Value: {Local.Missing}") + assert result == "Value: " + + +# --------------------------------------------------------------------------- +# Basic Executors Tests - Covering _executors_basic.py gaps +# --------------------------------------------------------------------------- + + +class TestBasicExecutorsCoverage: + """Tests for basic executors covering uncovered code paths.""" + + async def test_set_variable_executor(self, mock_context, mock_shared_state): + """Test SetVariableExecutor (distinct from SetValueExecutor).""" + from agent_framework_declarative._workflows._executors_basic import ( + SetVariableExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + action_def = { + "kind": "SetVariable", + "variable": "turn.result", + "value": "test value", + } + executor = SetVariableExecutor(action_def) + await executor.handle_action(ActionTrigger(), mock_context) + + result = await state.get("turn.result") + assert result == "test value" + + async def test_set_variable_executor_with_nested_variable(self, mock_context, mock_shared_state): + """Test SetVariableExecutor with nested variable object.""" + from agent_framework_declarative._workflows._executors_basic import ( + SetVariableExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + action_def = { + "kind": "SetVariable", + "variable": {"path": "turn.nested"}, + "value": 42, + } + executor = SetVariableExecutor(action_def) + await executor.handle_action(ActionTrigger(), mock_context) + + result = await state.get("turn.nested") + assert result == 42 + + async def test_set_text_variable_executor(self, mock_context, mock_shared_state): + """Test SetTextVariableExecutor.""" + from agent_framework_declarative._workflows._executors_basic import ( + SetTextVariableExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.name", "World") + + action_def = { + "kind": "SetTextVariable", + "variable": "turn.greeting", + "text": "=turn.name", + } + executor = SetTextVariableExecutor(action_def) + await executor.handle_action(ActionTrigger(), mock_context) + + result = await state.get("turn.greeting") + assert result == "World" + + async def test_set_text_variable_with_none(self, mock_context, mock_shared_state): + """Test SetTextVariableExecutor with None value converts to empty string.""" + from agent_framework_declarative._workflows._executors_basic import ( + SetTextVariableExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + action_def = { + "kind": "SetTextVariable", + "variable": "turn.result", + "text": "=turn.missing", + } + executor = SetTextVariableExecutor(action_def) + await executor.handle_action(ActionTrigger(), mock_context) + + result = await state.get("turn.result") + assert result == "" + + async def test_set_multiple_variables_executor(self, mock_context, mock_shared_state): + """Test SetMultipleVariablesExecutor.""" + from agent_framework_declarative._workflows._executors_basic import ( + SetMultipleVariablesExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + action_def = { + "kind": "SetMultipleVariables", + "assignments": [ + {"variable": "turn.a", "value": 1}, + {"variable": {"path": "turn.b"}, "value": 2}, + {"path": "turn.c", "value": 3}, + ], + } + executor = SetMultipleVariablesExecutor(action_def) + await executor.handle_action(ActionTrigger(), mock_context) + + assert await state.get("turn.a") == 1 + assert await state.get("turn.b") == 2 + assert await state.get("turn.c") == 3 + + async def test_append_value_executor(self, mock_context, mock_shared_state): + """Test AppendValueExecutor.""" + from agent_framework_declarative._workflows._executors_basic import ( + AppendValueExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.items", ["a"]) + + action_def = { + "kind": "AppendValue", + "path": "turn.items", + "value": "b", + } + executor = AppendValueExecutor(action_def) + await executor.handle_action(ActionTrigger(), mock_context) + + result = await state.get("turn.items") + assert result == ["a", "b"] + + async def test_reset_variable_executor(self, mock_context, mock_shared_state): + """Test ResetVariableExecutor.""" + from agent_framework_declarative._workflows._executors_basic import ( + ResetVariableExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.myVar", "some value") + + action_def = { + "kind": "ResetVariable", + "variable": "turn.myVar", + } + executor = ResetVariableExecutor(action_def) + await executor.handle_action(ActionTrigger(), mock_context) + + result = await state.get("turn.myVar") + assert result is None + + async def test_clear_all_variables_executor(self, mock_context, mock_shared_state): + """Test ClearAllVariablesExecutor.""" + from agent_framework_declarative._workflows._executors_basic import ( + ClearAllVariablesExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.a", 1) + await state.set("turn.b", 2) + + action_def = {"kind": "ClearAllVariables"} + executor = ClearAllVariablesExecutor(action_def) + await executor.handle_action(ActionTrigger(), mock_context) + + # Turn namespace should be cleared + assert await state.get("turn.a") is None + assert await state.get("turn.b") is None + + async def test_send_activity_with_dict_activity(self, mock_context, mock_shared_state): + """Test SendActivityExecutor with dict activity containing text field.""" + from agent_framework_declarative._workflows._executors_basic import ( + SendActivityExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.name", "Alice") + + action_def = { + "kind": "SendActivity", + "activity": {"text": "Hello, {Local.name}!"}, + } + executor = SendActivityExecutor(action_def) + await executor.handle_action(ActionTrigger(), mock_context) + + mock_context.yield_output.assert_called_once_with("Hello, Alice!") + + async def test_send_activity_with_string_activity(self, mock_context, mock_shared_state): + """Test SendActivityExecutor with string activity.""" + from agent_framework_declarative._workflows._executors_basic import ( + SendActivityExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + action_def = { + "kind": "SendActivity", + "activity": "Plain text message", + } + executor = SendActivityExecutor(action_def) + await executor.handle_action(ActionTrigger(), mock_context) + + mock_context.yield_output.assert_called_once_with("Plain text message") + + async def test_send_activity_with_expression(self, mock_context, mock_shared_state): + """Test SendActivityExecutor evaluates expressions.""" + from agent_framework_declarative._workflows._executors_basic import ( + SendActivityExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.msg", "Dynamic message") + + action_def = { + "kind": "SendActivity", + "activity": "=turn.msg", + } + executor = SendActivityExecutor(action_def) + await executor.handle_action(ActionTrigger(), mock_context) + + mock_context.yield_output.assert_called_once_with("Dynamic message") + + async def test_emit_event_executor_graph_mode(self, mock_context, mock_shared_state): + """Test EmitEventExecutor with graph-mode schema (eventName/eventValue).""" + from agent_framework_declarative._workflows._executors_basic import ( + EmitEventExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + action_def = { + "kind": "EmitEvent", + "eventName": "myEvent", + "eventValue": {"key": "value"}, + } + executor = EmitEventExecutor(action_def) + await executor.handle_action(ActionTrigger(), mock_context) + + mock_context.yield_output.assert_called_once() + event_data = mock_context.yield_output.call_args[0][0] + assert event_data["eventName"] == "myEvent" + assert event_data["eventValue"] == {"key": "value"} + + async def test_emit_event_executor_interpreter_mode(self, mock_context, mock_shared_state): + """Test EmitEventExecutor with interpreter-mode schema (event.name/event.data).""" + from agent_framework_declarative._workflows._executors_basic import ( + EmitEventExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + action_def = { + "kind": "EmitEvent", + "event": { + "name": "interpreterEvent", + "data": {"payload": "test"}, + }, + } + executor = EmitEventExecutor(action_def) + await executor.handle_action(ActionTrigger(), mock_context) + + mock_context.yield_output.assert_called_once() + event_data = mock_context.yield_output.call_args[0][0] + assert event_data["eventName"] == "interpreterEvent" + assert event_data["eventValue"] == {"payload": "test"} + + +# --------------------------------------------------------------------------- +# Agent Executors Tests - Covering _executors_agents.py gaps +# --------------------------------------------------------------------------- + + +class TestAgentExecutorsCoverage: + """Tests for agent executors covering uncovered code paths.""" + + async def test_map_variable_to_path_all_cases(self): + """Test _map_variable_to_path with all namespace mappings.""" + from agent_framework_declarative._workflows._executors_agents import ( + _map_variable_to_path, + ) + + # Local. -> turn. + assert _map_variable_to_path("Local.MyVar") == "turn.MyVar" + + # System. -> system. + assert _map_variable_to_path("System.ConvId") == "system.ConvId" + + # Workflow. -> workflow. + assert _map_variable_to_path("Workflow.outputs.result") == "workflow.outputs.result" + + # Already has dots - pass through + assert _map_variable_to_path("turn.existing") == "turn.existing" + + # No namespace - default to turn. + assert _map_variable_to_path("simpleVar") == "turn.simpleVar" + + async def test_agent_executor_get_agent_name_string(self, mock_context, mock_shared_state): + """Test agent name extraction from simple string config.""" + from agent_framework_declarative._workflows._executors_agents import ( + InvokeAzureAgentExecutor, + ) + + action_def = { + "kind": "InvokeAzureAgent", + "agent": "MyAgent", + } + executor = InvokeAzureAgentExecutor(action_def) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + name = executor._get_agent_name(state) + assert name == "MyAgent" + + async def test_agent_executor_get_agent_name_dict(self, mock_context, mock_shared_state): + """Test agent name extraction from nested dict config.""" + from agent_framework_declarative._workflows._executors_agents import ( + InvokeAzureAgentExecutor, + ) + + action_def = { + "kind": "InvokeAzureAgent", + "agent": {"name": "NestedAgent"}, + } + executor = InvokeAzureAgentExecutor(action_def) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + name = executor._get_agent_name(state) + assert name == "NestedAgent" + + async def test_agent_executor_get_agent_name_legacy(self, mock_context, mock_shared_state): + """Test agent name extraction from agentName (legacy).""" + from agent_framework_declarative._workflows._executors_agents import ( + InvokeAzureAgentExecutor, + ) + + action_def = { + "kind": "InvokeAzureAgent", + "agentName": "LegacyAgent", + } + executor = InvokeAzureAgentExecutor(action_def) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + name = executor._get_agent_name(state) + assert name == "LegacyAgent" + + async def test_agent_executor_get_input_config_simple(self, mock_context, mock_shared_state): + """Test input config parsing with simple non-dict input.""" + from agent_framework_declarative._workflows._executors_agents import ( + InvokeAzureAgentExecutor, + ) + + action_def = { + "kind": "InvokeAzureAgent", + "agent": "TestAgent", + "input": "simple string input", + } + executor = InvokeAzureAgentExecutor(action_def) + + args, messages, external_loop, max_iterations = executor._get_input_config() + assert args == {} + assert messages == "simple string input" + assert external_loop is None + assert max_iterations == 100 # Default + + async def test_agent_executor_get_input_config_full(self, mock_context, mock_shared_state): + """Test input config parsing with full structured input.""" + from agent_framework_declarative._workflows._executors_agents import ( + InvokeAzureAgentExecutor, + ) + + action_def = { + "kind": "InvokeAzureAgent", + "agent": "TestAgent", + "input": { + "arguments": {"param1": "=turn.value"}, + "messages": "=conversation.messages", + "externalLoop": {"when": "=turn.needsMore", "maxIterations": 50}, + }, + } + executor = InvokeAzureAgentExecutor(action_def) + + args, messages, external_loop, max_iterations = executor._get_input_config() + assert args == {"param1": "=turn.value"} + assert messages == "=conversation.messages" + assert external_loop == "=turn.needsMore" + assert max_iterations == 50 + + async def test_agent_executor_get_output_config_simple(self, mock_context, mock_shared_state): + """Test output config parsing with simple resultProperty.""" + from agent_framework_declarative._workflows._executors_agents import ( + InvokeAzureAgentExecutor, + ) + + action_def = { + "kind": "InvokeAzureAgent", + "agent": "TestAgent", + "resultProperty": "turn.result", + } + executor = InvokeAzureAgentExecutor(action_def) + + messages_var, response_obj, result_prop, auto_send = executor._get_output_config() + assert messages_var is None + assert response_obj is None + assert result_prop == "turn.result" + assert auto_send is True + + async def test_agent_executor_get_output_config_full(self, mock_context, mock_shared_state): + """Test output config parsing with full structured output.""" + from agent_framework_declarative._workflows._executors_agents import ( + InvokeAzureAgentExecutor, + ) + + action_def = { + "kind": "InvokeAzureAgent", + "agent": "TestAgent", + "output": { + "messages": "Local.ResponseMessages", + "responseObject": "Local.ParsedResponse", + "property": "turn.result", + "autoSend": False, + }, + } + executor = InvokeAzureAgentExecutor(action_def) + + messages_var, response_obj, result_prop, auto_send = executor._get_output_config() + assert messages_var == "Local.ResponseMessages" + assert response_obj == "Local.ParsedResponse" + assert result_prop == "turn.result" + assert auto_send is False + + async def test_agent_executor_build_input_text_from_string_messages(self, mock_context, mock_shared_state): + """Test _build_input_text with string messages expression.""" + from agent_framework_declarative._workflows._executors_agents import ( + InvokeAzureAgentExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.userInput", "Hello agent!") + + action_def = {"kind": "InvokeAzureAgent", "agent": "Test"} + executor = InvokeAzureAgentExecutor(action_def) + + input_text = await executor._build_input_text(state, {}, "=turn.userInput") + assert input_text == "Hello agent!" + + async def test_agent_executor_build_input_text_from_message_list(self, mock_context, mock_shared_state): + """Test _build_input_text extracts text from message list.""" + from agent_framework_declarative._workflows._executors_agents import ( + InvokeAzureAgentExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set( + "conversation.messages", + [ + {"role": "user", "content": "First"}, + {"role": "assistant", "content": "Response"}, + {"role": "user", "content": "Last message"}, + ], + ) + + action_def = {"kind": "InvokeAzureAgent", "agent": "Test"} + executor = InvokeAzureAgentExecutor(action_def) + + input_text = await executor._build_input_text(state, {}, "=conversation.messages") + assert input_text == "Last message" + + async def test_agent_executor_build_input_text_from_message_with_text_attr(self, mock_context, mock_shared_state): + """Test _build_input_text extracts text from message with text attribute.""" + from agent_framework_declarative._workflows._executors_agents import ( + InvokeAzureAgentExecutor, + ) + + @dataclass + class MockMessage: + text: str + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.messages", [MockMessage(text="From attribute")]) + + action_def = {"kind": "InvokeAzureAgent", "agent": "Test"} + executor = InvokeAzureAgentExecutor(action_def) + + input_text = await executor._build_input_text(state, {}, "=turn.messages") + assert input_text == "From attribute" + + async def test_agent_executor_build_input_text_fallback_chain(self, mock_context, mock_shared_state): + """Test _build_input_text fallback chain when no messages expression.""" + from agent_framework_declarative._workflows._executors_agents import ( + InvokeAzureAgentExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize({"query": "workflow input"}) + + action_def = {"kind": "InvokeAzureAgent", "agent": "Test"} + executor = InvokeAzureAgentExecutor(action_def) + + # No messages_expr, so falls back to workflow.inputs + input_text = await executor._build_input_text(state, {}, None) + assert input_text == "workflow input" + + async def test_agent_executor_build_input_text_from_system_last_message(self, mock_context, mock_shared_state): + """Test _build_input_text falls back to system.LastMessage.Text.""" + from agent_framework_declarative._workflows._executors_agents import ( + InvokeAzureAgentExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("system.LastMessage", {"Text": "From last message"}) + + action_def = {"kind": "InvokeAzureAgent", "agent": "Test"} + executor = InvokeAzureAgentExecutor(action_def) + + input_text = await executor._build_input_text(state, {}, None) + assert input_text == "From last message" + + async def test_agent_executor_missing_agent_name(self, mock_context, mock_shared_state): + """Test agent executor with missing agent name logs warning.""" + from agent_framework_declarative._workflows._executors_agents import ( + InvokeAzureAgentExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + action_def = {"kind": "InvokeAzureAgent"} # No agent specified + executor = InvokeAzureAgentExecutor(action_def) + + await executor.handle_action(ActionTrigger(), mock_context) + + # Should complete without error + mock_context.send_message.assert_called_once() + msg = mock_context.send_message.call_args[0][0] + assert isinstance(msg, ActionComplete) + + async def test_agent_executor_with_working_agent(self, mock_context, mock_shared_state): + """Test agent executor with a working mock agent.""" + from agent_framework_declarative._workflows._executors_agents import ( + InvokeAzureAgentExecutor, + ) + + # Create mock agent + @dataclass + class MockResult: + text: str + messages: list[Any] + + mock_agent = MagicMock() + mock_agent.run = AsyncMock(return_value=MockResult(text="Agent response", messages=[])) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.input", "User query") + + action_def = { + "kind": "InvokeAzureAgent", + "agent": "TestAgent", + "resultProperty": "turn.result", + } + executor = InvokeAzureAgentExecutor(action_def, agents={"TestAgent": mock_agent}) + + await executor.handle_action(ActionTrigger(), mock_context) + + # Verify agent was called + mock_agent.run.assert_called_once() + + # Verify result was stored + result = await state.get("turn.result") + assert result == "Agent response" + + # Verify agent state was set + assert await state.get("agent.response") == "Agent response" + assert await state.get("agent.name") == "TestAgent" + assert await state.get("agent.text") == "Agent response" + + async def test_agent_executor_with_agent_from_registry(self, mock_context, mock_shared_state): + """Test agent executor retrieves agent from shared state registry.""" + from agent_framework_declarative._workflows._executors_agents import ( + AGENT_REGISTRY_KEY, + InvokeAzureAgentExecutor, + ) + + # Create mock agent + @dataclass + class MockResult: + text: str + messages: list[Any] + + mock_agent = MagicMock() + mock_agent.run = AsyncMock(return_value=MockResult(text="Registry agent", messages=[])) + + # Store in registry + mock_shared_state._data[AGENT_REGISTRY_KEY] = {"RegistryAgent": mock_agent} + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.input", "Query") + + action_def = { + "kind": "InvokeAzureAgent", + "agent": "RegistryAgent", + } + executor = InvokeAzureAgentExecutor(action_def) + + await executor.handle_action(ActionTrigger(), mock_context) + + mock_agent.run.assert_called_once() + + async def test_agent_executor_parses_json_response(self, mock_context, mock_shared_state): + """Test agent executor parses JSON response into responseObject.""" + from agent_framework_declarative._workflows._executors_agents import ( + InvokeAzureAgentExecutor, + ) + + @dataclass + class MockResult: + text: str + messages: list[Any] + + mock_agent = MagicMock() + mock_agent.run = AsyncMock(return_value=MockResult(text='{"status": "ok", "count": 42}', messages=[])) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.input", "Query") + + action_def = { + "kind": "InvokeAzureAgent", + "agent": "TestAgent", + "output": { + "responseObject": "Local.Parsed", + }, + } + executor = InvokeAzureAgentExecutor(action_def, agents={"TestAgent": mock_agent}) + + await executor.handle_action(ActionTrigger(), mock_context) + + parsed = await state.get("turn.Parsed") + assert parsed == {"status": "ok", "count": 42} + + async def test_invoke_tool_executor_not_found(self, mock_context, mock_shared_state): + """Test InvokeToolExecutor when tool not found.""" + from agent_framework_declarative._workflows._executors_agents import ( + InvokeToolExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + action_def = { + "kind": "InvokeTool", + "tool": "MissingTool", + "resultProperty": "turn.result", + } + executor = InvokeToolExecutor(action_def) + + await executor.handle_action(ActionTrigger(), mock_context) + + result = await state.get("turn.result") + assert result == {"error": "Tool 'MissingTool' not found in registry"} + + async def test_invoke_tool_executor_sync_tool(self, mock_context, mock_shared_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_shared_state._data[TOOL_REGISTRY_KEY] = {"add": my_tool} + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + action_def = { + "kind": "InvokeTool", + "tool": "add", + "parameters": {"x": 5, "y": 3}, + "resultProperty": "turn.result", + } + executor = InvokeToolExecutor(action_def) + + await executor.handle_action(ActionTrigger(), mock_context) + + result = await state.get("turn.result") + assert result == 8 + + async def test_invoke_tool_executor_async_tool(self, mock_context, mock_shared_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_shared_state._data[TOOL_REGISTRY_KEY] = {"process": my_async_tool} + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + action_def = { + "kind": "InvokeTool", + "tool": "process", + "input": "test data", + "resultProperty": "turn.result", + } + executor = InvokeToolExecutor(action_def) + + await executor.handle_action(ActionTrigger(), mock_context) + + result = await state.get("turn.result") + assert result == "Processed: test data" + + +# --------------------------------------------------------------------------- +# Control Flow Executors Tests - Additional coverage +# --------------------------------------------------------------------------- + + +class TestControlFlowCoverage: + """Tests for control flow executors covering uncovered code paths.""" + + async def test_foreach_with_source_alias(self, mock_context, mock_shared_state): + """Test ForeachInitExecutor with 'source' alias (interpreter mode).""" + from agent_framework_declarative._workflows._executors_control_flow import ( + ForeachInitExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.data", [10, 20, 30]) + + action_def = { + "kind": "Foreach", + "source": "=turn.data", + "itemName": "item", + "indexName": "idx", + } + executor = ForeachInitExecutor(action_def) + + await executor.handle_action(ActionTrigger(), mock_context) + + msg = mock_context.send_message.call_args[0][0] + assert isinstance(msg, LoopIterationResult) + assert msg.has_next is True + assert msg.current_item == 10 + assert msg.current_index == 0 + + async def test_foreach_next_continues_iteration(self, mock_context, mock_shared_state): + """Test ForeachNextExecutor continues to next item.""" + from agent_framework_declarative._workflows._executors_control_flow import ( + LOOP_STATE_KEY, + ForeachNextExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.data", ["a", "b", "c"]) + + # Set up loop state as ForeachInitExecutor would + state_data = await state.get_state_data() + state_data[LOOP_STATE_KEY] = { + "foreach_init": { + "items": ["a", "b", "c"], + "index": 0, + "length": 3, + } + } + await state.set_state_data(state_data) + + action_def = { + "kind": "Foreach", + "itemsSource": "=turn.data", + "iteratorVariable": "turn.item", + } + executor = ForeachNextExecutor(action_def, init_executor_id="foreach_init") + + await executor.handle_action(LoopIterationResult(has_next=True), mock_context) + + msg = mock_context.send_message.call_args[0][0] + assert isinstance(msg, LoopIterationResult) + assert msg.current_index == 1 + assert msg.current_item == "b" + + async def test_switch_evaluator_with_value_cases(self, mock_context, mock_shared_state): + """Test SwitchEvaluatorExecutor with value/cases schema.""" + from agent_framework_declarative._workflows._executors_control_flow import ( + SwitchEvaluatorExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.status", "pending") + + action_def = { + "kind": "Switch", + "value": "=turn.status", + } + cases = [ + {"match": "active"}, + {"match": "pending"}, + ] + executor = SwitchEvaluatorExecutor(action_def, cases=cases) + + await executor.handle_action(ActionTrigger(), mock_context) + + msg = mock_context.send_message.call_args[0][0] + assert isinstance(msg, ConditionResult) + assert msg.matched is True + assert msg.branch_index == 1 # Second case matched + + async def test_switch_evaluator_default_case(self, mock_context, mock_shared_state): + """Test SwitchEvaluatorExecutor falls through to default.""" + from agent_framework_declarative._workflows._executors_control_flow import ( + SwitchEvaluatorExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.status", "unknown") + + action_def = { + "kind": "Switch", + "value": "=turn.status", + } + cases = [ + {"match": "active"}, + {"match": "pending"}, + ] + executor = SwitchEvaluatorExecutor(action_def, cases=cases) + + await executor.handle_action(ActionTrigger(), mock_context) + + msg = mock_context.send_message.call_args[0][0] + assert isinstance(msg, ConditionResult) + assert msg.matched is False + assert msg.branch_index == -1 # Default case + + async def test_switch_evaluator_no_value(self, mock_context, mock_shared_state): + """Test SwitchEvaluatorExecutor with no value defaults to else.""" + from agent_framework_declarative._workflows._executors_control_flow import ( + SwitchEvaluatorExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + action_def = {"kind": "Switch"} # No value + cases = [{"match": "x"}] + executor = SwitchEvaluatorExecutor(action_def, cases=cases) + + await executor.handle_action(ActionTrigger(), mock_context) + + msg = mock_context.send_message.call_args[0][0] + assert isinstance(msg, ConditionResult) + assert msg.branch_index == -1 + + async def test_join_executor_accepts_condition_result(self, mock_context, mock_shared_state): + """Test JoinExecutor accepts ConditionResult as trigger.""" + from agent_framework_declarative._workflows._executors_control_flow import ( + JoinExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + action_def = {"kind": "_Join"} + executor = JoinExecutor(action_def) + + # Trigger with ConditionResult + await executor.handle_action(ConditionResult(matched=True, branch_index=0), mock_context) + + msg = mock_context.send_message.call_args[0][0] + assert isinstance(msg, ActionComplete) + + async def test_break_loop_executor(self, mock_context, mock_shared_state): + """Test BreakLoopExecutor emits LoopControl.""" + from agent_framework_declarative._workflows._executors_control_flow import ( + BreakLoopExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + action_def = {"kind": "BreakLoop"} + executor = BreakLoopExecutor(action_def, loop_next_executor_id="loop_next") + + await executor.handle_action(ActionTrigger(), mock_context) + + msg = mock_context.send_message.call_args[0][0] + assert isinstance(msg, LoopControl) + assert msg.action == "break" + + async def test_continue_loop_executor(self, mock_context, mock_shared_state): + """Test ContinueLoopExecutor emits LoopControl.""" + from agent_framework_declarative._workflows._executors_control_flow import ( + ContinueLoopExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + action_def = {"kind": "ContinueLoop"} + executor = ContinueLoopExecutor(action_def, loop_next_executor_id="loop_next") + + await executor.handle_action(ActionTrigger(), mock_context) + + msg = mock_context.send_message.call_args[0][0] + assert isinstance(msg, LoopControl) + assert msg.action == "continue" + + async def test_foreach_next_no_loop_state(self, mock_context, mock_shared_state): + """Test ForeachNextExecutor with missing loop state.""" + from agent_framework_declarative._workflows._executors_control_flow import ( + ForeachNextExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + action_def = { + "kind": "Foreach", + "itemsSource": "=turn.data", + "iteratorVariable": "turn.item", + } + executor = ForeachNextExecutor(action_def, init_executor_id="missing_loop") + + await executor.handle_action(ActionTrigger(), mock_context) + + msg = mock_context.send_message.call_args[0][0] + assert isinstance(msg, LoopIterationResult) + assert msg.has_next is False + + async def test_foreach_next_loop_complete(self, mock_context, mock_shared_state): + """Test ForeachNextExecutor when loop is complete.""" + from agent_framework_declarative._workflows._executors_control_flow import ( + LOOP_STATE_KEY, + ForeachNextExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + # Set up loop state at last item + state_data = await state.get_state_data() + state_data[LOOP_STATE_KEY] = { + "loop_id": { + "items": ["a", "b"], + "index": 1, # Already at last item + "length": 2, + } + } + await state.set_state_data(state_data) + + action_def = { + "kind": "Foreach", + "itemsSource": "=turn.data", + "iteratorVariable": "turn.item", + } + executor = ForeachNextExecutor(action_def, init_executor_id="loop_id") + + await executor.handle_action(ActionTrigger(), mock_context) + + msg = mock_context.send_message.call_args[0][0] + assert isinstance(msg, LoopIterationResult) + assert msg.has_next is False + + async def test_foreach_next_handle_break_control(self, mock_context, mock_shared_state): + """Test ForeachNextExecutor handles break LoopControl.""" + from agent_framework_declarative._workflows._executors_control_flow import ( + LOOP_STATE_KEY, + ForeachNextExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + # Set up loop state + state_data = await state.get_state_data() + state_data[LOOP_STATE_KEY] = { + "loop_id": { + "items": ["a", "b", "c"], + "index": 0, + "length": 3, + } + } + await state.set_state_data(state_data) + + action_def = { + "kind": "Foreach", + "itemsSource": "=turn.data", + "iteratorVariable": "turn.item", + } + executor = ForeachNextExecutor(action_def, init_executor_id="loop_id") + + await executor.handle_loop_control(LoopControl(action="break"), mock_context) + + msg = mock_context.send_message.call_args[0][0] + assert isinstance(msg, LoopIterationResult) + assert msg.has_next is False + + async def test_foreach_next_handle_continue_control(self, mock_context, mock_shared_state): + """Test ForeachNextExecutor handles continue LoopControl.""" + from agent_framework_declarative._workflows._executors_control_flow import ( + LOOP_STATE_KEY, + ForeachNextExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + # Set up loop state + state_data = await state.get_state_data() + state_data[LOOP_STATE_KEY] = { + "loop_id": { + "items": ["a", "b", "c"], + "index": 0, + "length": 3, + } + } + await state.set_state_data(state_data) + + action_def = { + "kind": "Foreach", + "itemsSource": "=turn.data", + "iteratorVariable": "turn.item", + } + executor = ForeachNextExecutor(action_def, init_executor_id="loop_id") + + await executor.handle_loop_control(LoopControl(action="continue"), mock_context) + + msg = mock_context.send_message.call_args[0][0] + assert isinstance(msg, LoopIterationResult) + assert msg.has_next is True + assert msg.current_index == 1 + + async def test_end_workflow_executor(self, mock_context, mock_shared_state): + """Test EndWorkflowExecutor does not send continuation.""" + from agent_framework_declarative._workflows._executors_control_flow import ( + EndWorkflowExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + action_def = {"kind": "EndWorkflow"} + executor = EndWorkflowExecutor(action_def) + + await executor.handle_action(ActionTrigger(), mock_context) + + # Should NOT send any message + mock_context.send_message.assert_not_called() + + async def test_end_conversation_executor(self, mock_context, mock_shared_state): + """Test EndConversationExecutor does not send continuation.""" + from agent_framework_declarative._workflows._executors_control_flow import ( + EndConversationExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + action_def = {"kind": "EndConversation"} + executor = EndConversationExecutor(action_def) + + await executor.handle_action(ActionTrigger(), mock_context) + + # Should NOT send any message + mock_context.send_message.assert_not_called() + + async def test_condition_group_evaluator_first_match(self, mock_context, mock_shared_state): + """Test ConditionGroupEvaluatorExecutor returns first match.""" + from agent_framework_declarative._workflows._executors_control_flow import ( + ConditionGroupEvaluatorExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.x", 10) + + action_def = {"kind": "ConditionGroup"} + conditions = [ + {"condition": "=turn.x > 20"}, + {"condition": "=turn.x > 5"}, + {"condition": "=turn.x > 0"}, + ] + executor = ConditionGroupEvaluatorExecutor(action_def, conditions=conditions) + + await executor.handle_action(ActionTrigger(), mock_context) + + msg = mock_context.send_message.call_args[0][0] + assert isinstance(msg, ConditionResult) + assert msg.matched is True + assert msg.branch_index == 1 # Second condition (x > 5) is first match + + async def test_condition_group_evaluator_no_match(self, mock_context, mock_shared_state): + """Test ConditionGroupEvaluatorExecutor with no matches.""" + from agent_framework_declarative._workflows._executors_control_flow import ( + ConditionGroupEvaluatorExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.x", 0) + + action_def = {"kind": "ConditionGroup"} + conditions = [ + {"condition": "=turn.x > 10"}, + {"condition": "=turn.x > 5"}, + ] + executor = ConditionGroupEvaluatorExecutor(action_def, conditions=conditions) + + await executor.handle_action(ActionTrigger(), mock_context) + + msg = mock_context.send_message.call_args[0][0] + assert isinstance(msg, ConditionResult) + assert msg.matched is False + assert msg.branch_index == -1 + + async def test_condition_group_evaluator_boolean_true_condition(self, mock_context, mock_shared_state): + """Test ConditionGroupEvaluatorExecutor with boolean True condition.""" + from agent_framework_declarative._workflows._executors_control_flow import ( + ConditionGroupEvaluatorExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + action_def = {"kind": "ConditionGroup"} + conditions = [ + {"condition": False}, # Should skip + {"condition": True}, # Should match + ] + executor = ConditionGroupEvaluatorExecutor(action_def, conditions=conditions) + + await executor.handle_action(ActionTrigger(), mock_context) + + msg = mock_context.send_message.call_args[0][0] + assert isinstance(msg, ConditionResult) + assert msg.matched is True + assert msg.branch_index == 1 + + async def test_if_condition_evaluator_true(self, mock_context, mock_shared_state): + """Test IfConditionEvaluatorExecutor with true condition.""" + from agent_framework_declarative._workflows._executors_control_flow import ( + IfConditionEvaluatorExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.flag", True) + + action_def = {"kind": "If"} + executor = IfConditionEvaluatorExecutor(action_def, condition_expr="=turn.flag") + + await executor.handle_action(ActionTrigger(), mock_context) + + msg = mock_context.send_message.call_args[0][0] + assert isinstance(msg, ConditionResult) + assert msg.matched is True + assert msg.branch_index == 0 # Then branch + + async def test_if_condition_evaluator_false(self, mock_context, mock_shared_state): + """Test IfConditionEvaluatorExecutor with false condition.""" + from agent_framework_declarative._workflows._executors_control_flow import ( + IfConditionEvaluatorExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.flag", False) + + action_def = {"kind": "If"} + executor = IfConditionEvaluatorExecutor(action_def, condition_expr="=turn.flag") + + await executor.handle_action(ActionTrigger(), mock_context) + + msg = mock_context.send_message.call_args[0][0] + assert isinstance(msg, ConditionResult) + assert msg.matched is False + assert msg.branch_index == -1 # Else branch + + +# --------------------------------------------------------------------------- +# Declarative Action Executor Base Tests +# --------------------------------------------------------------------------- + + +class TestDeclarativeActionExecutorBase: + """Tests for DeclarativeActionExecutor base class.""" + + async def test_ensure_state_initialized_with_dict_input(self, mock_context, mock_shared_state): + """Test _ensure_state_initialized with dict input.""" + from agent_framework_declarative._workflows._executors_basic import ( + SetValueExecutor, + ) + + action_def = {"kind": "SetValue", "path": "turn.x", "value": 1} + executor = SetValueExecutor(action_def) + + # Trigger with dict - should initialize state with it + await executor.handle_action({"custom": "input"}, mock_context) + + # State should have been initialized with the dict + state = DeclarativeWorkflowState(mock_shared_state) + inputs = await state.get_inputs() + assert inputs == {"custom": "input"} + + async def test_ensure_state_initialized_with_string_input(self, mock_context, mock_shared_state): + """Test _ensure_state_initialized with string input.""" + from agent_framework_declarative._workflows._executors_basic import ( + SetValueExecutor, + ) + + action_def = {"kind": "SetValue", "path": "turn.x", "value": 1} + executor = SetValueExecutor(action_def) + + # Trigger with string - should wrap in {"input": ...} + await executor.handle_action("string trigger", mock_context) + + state = DeclarativeWorkflowState(mock_shared_state) + inputs = await state.get_inputs() + assert inputs == {"input": "string trigger"} + + async def test_ensure_state_initialized_with_custom_object(self, mock_context, mock_shared_state): + """Test _ensure_state_initialized with custom object converts to string.""" + from agent_framework_declarative._workflows._executors_basic import ( + SetValueExecutor, + ) + + class CustomObj: + def __str__(self): + return "custom string" + + action_def = {"kind": "SetValue", "path": "turn.x", "value": 1} + executor = SetValueExecutor(action_def) + + await executor.handle_action(CustomObj(), mock_context) + + state = DeclarativeWorkflowState(mock_shared_state) + inputs = await state.get_inputs() + assert inputs == {"input": "custom string"} + + async def test_executor_display_name_property(self, mock_context, mock_shared_state): + """Test executor display_name property.""" + from agent_framework_declarative._workflows._executors_basic import ( + SetValueExecutor, + ) + + action_def = { + "kind": "SetValue", + "displayName": "My Custom Action", + "path": "turn.x", + "value": 1, + } + executor = SetValueExecutor(action_def) + + assert executor.display_name == "My Custom Action" + + async def test_executor_action_def_property(self, mock_context, mock_shared_state): + """Test executor action_def property.""" + from agent_framework_declarative._workflows._executors_basic import ( + SetValueExecutor, + ) + + action_def = {"kind": "SetValue", "path": "turn.x", "value": 1} + executor = SetValueExecutor(action_def) + + assert executor.action_def == action_def + + +# --------------------------------------------------------------------------- +# Human Input Executors Tests - Covering _executors_external_input.py gaps +# --------------------------------------------------------------------------- + + +class TestHumanInputExecutorsCoverage: + """Tests for human input executors covering uncovered code paths.""" + + async def test_wait_for_input_executor_with_prompt(self, mock_context, mock_shared_state): + """Test WaitForInputExecutor with prompt.""" + from agent_framework_declarative._workflows._executors_external_input import ( + HumanInputRequest, + WaitForInputExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + action_def = { + "kind": "WaitForInput", + "prompt": "Please enter your name:", + "property": "turn.userName", + "timeout": 30, + } + executor = WaitForInputExecutor(action_def) + + await executor.handle_action(ActionTrigger(), mock_context) + + # Should yield prompt first, then request + assert mock_context.yield_output.call_count == 2 + # First call: prompt text + assert mock_context.yield_output.call_args_list[0][0][0] == "Please enter your name:" + # Second call: HumanInputRequest + request = mock_context.yield_output.call_args_list[1][0][0] + assert isinstance(request, HumanInputRequest) + assert request.request_type == "user_input" + + async def test_wait_for_input_executor_no_prompt(self, mock_context, mock_shared_state): + """Test WaitForInputExecutor without prompt.""" + from agent_framework_declarative._workflows._executors_external_input import ( + HumanInputRequest, + WaitForInputExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + action_def = { + "kind": "WaitForInput", + "property": "turn.input", + } + executor = WaitForInputExecutor(action_def) + + await executor.handle_action(ActionTrigger(), mock_context) + + # Should only yield the request (no prompt) + assert mock_context.yield_output.call_count == 1 + request = mock_context.yield_output.call_args[0][0] + assert isinstance(request, HumanInputRequest) + assert request.request_type == "user_input" + + async def test_request_external_input_executor(self, mock_context, mock_shared_state): + """Test RequestExternalInputExecutor.""" + from agent_framework_declarative._workflows._executors_external_input import ( + HumanInputRequest, + RequestExternalInputExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + action_def = { + "kind": "RequestExternalInput", + "requestType": "approval", + "message": "Please approve this request", + "property": "turn.approvalResult", + "timeout": 3600, + "requiredFields": ["approver", "notes"], + "metadata": {"priority": "high"}, + } + executor = RequestExternalInputExecutor(action_def) + + await executor.handle_action(ActionTrigger(), mock_context) + + request = mock_context.yield_output.call_args[0][0] + assert isinstance(request, HumanInputRequest) + assert request.request_type == "approval" + assert request.message == "Please approve this request" + assert request.metadata["priority"] == "high" + assert request.metadata["required_fields"] == ["approver", "notes"] + assert request.metadata["timeout_seconds"] == 3600 + + async def test_question_executor_with_choices(self, mock_context, mock_shared_state): + """Test QuestionExecutor with choices as dicts and strings.""" + from agent_framework_declarative._workflows._executors_external_input import ( + HumanInputRequest, + QuestionExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + action_def = { + "kind": "Question", + "question": "Select an option:", + "property": "turn.selection", + "choices": [ + {"value": "a", "label": "Option A"}, + {"value": "b"}, # No label, should use value + "c", # String choice + ], + "allowFreeText": False, + } + executor = QuestionExecutor(action_def) + + await executor.handle_action(ActionTrigger(), mock_context) + + request = mock_context.yield_output.call_args[0][0] + assert isinstance(request, HumanInputRequest) + assert request.request_type == "question" + choices = request.metadata["choices"] + assert len(choices) == 3 + assert choices[0] == {"value": "a", "label": "Option A"} + assert choices[1] == {"value": "b", "label": "b"} + assert choices[2] == {"value": "c", "label": "c"} + assert request.metadata["allow_free_text"] is False + + +# --------------------------------------------------------------------------- +# Additional Agent Executor Tests - External Loop Coverage +# --------------------------------------------------------------------------- + + +class TestAgentExternalLoopCoverage: + """Tests for agent executor external loop handling.""" + + async def test_agent_executor_with_external_loop(self, mock_context, mock_shared_state): + """Test agent executor with external loop that triggers.""" + from agent_framework_declarative._workflows._executors_agents import ( + ExternalInputRequest, + InvokeAzureAgentExecutor, + ) + + @dataclass + class MockResult: + text: str + messages: list[Any] + + mock_agent = MagicMock() + mock_agent.run = AsyncMock(return_value=MockResult(text="Need more info", messages=[])) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.input", "User query") + await state.set("turn.needsMore", True) # Loop condition will be true + + action_def = { + "kind": "InvokeAzureAgent", + "agent": "TestAgent", + "input": { + "externalLoop": {"when": "=turn.needsMore"}, + }, + } + executor = InvokeAzureAgentExecutor(action_def, agents={"TestAgent": mock_agent}) + + await executor.handle_action(ActionTrigger(), mock_context) + + # Should request external input via request_info + mock_context.request_info.assert_called_once() + request = mock_context.request_info.call_args[0][0] + assert isinstance(request, ExternalInputRequest) + assert request.agent_name == "TestAgent" + + async def test_agent_executor_agent_error_handling(self, mock_context, mock_shared_state): + """Test agent executor raises AgentInvocationError on failure.""" + from agent_framework_declarative._workflows._executors_agents import ( + AgentInvocationError, + InvokeAzureAgentExecutor, + ) + + mock_agent = MagicMock() + mock_agent.run = AsyncMock(side_effect=RuntimeError("Agent failed")) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.input", "Query") + + action_def = { + "kind": "InvokeAzureAgent", + "agent": "TestAgent", + "resultProperty": "turn.result", + } + executor = InvokeAzureAgentExecutor(action_def, agents={"TestAgent": mock_agent}) + + with pytest.raises(AgentInvocationError) as exc_info: + await executor.handle_action(ActionTrigger(), mock_context) + + assert "TestAgent" in str(exc_info.value) + assert "Agent failed" in str(exc_info.value) + + # Should still store error in state before raising + error = await state.get("agent.error") + assert "Agent failed" in error + result = await state.get("turn.result") + assert result == {"error": "Agent failed"} + + async def test_agent_executor_string_result(self, mock_context, mock_shared_state): + """Test agent executor with agent that returns string directly.""" + from agent_framework_declarative._workflows._executors_agents import ( + InvokeAzureAgentExecutor, + ) + + mock_agent = MagicMock() + mock_agent.run = AsyncMock(return_value="Direct string response") + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.input", "Query") + + action_def = { + "kind": "InvokeAzureAgent", + "agent": "TestAgent", + "resultProperty": "turn.result", + "output": {"autoSend": True}, + } + executor = InvokeAzureAgentExecutor(action_def, agents={"TestAgent": mock_agent}) + + await executor.handle_action(ActionTrigger(), mock_context) + + # Should auto-send output + mock_context.yield_output.assert_called_with("Direct string response") + result = await state.get("turn.result") + assert result == "Direct string response" + + async def test_invoke_tool_with_error(self, mock_context, mock_shared_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_shared_state._data[TOOL_REGISTRY_KEY] = {"bad_tool": failing_tool} + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + action_def = { + "kind": "InvokeTool", + "tool": "bad_tool", + "resultProperty": "turn.result", + } + executor = InvokeToolExecutor(action_def) + + await executor.handle_action(ActionTrigger(), mock_context) + + result = await state.get("turn.result") + assert result == {"error": "Tool error"} + + +# --------------------------------------------------------------------------- +# PowerFx Functions Coverage +# --------------------------------------------------------------------------- + + +class TestPowerFxFunctionsCoverage: + """Tests for PowerFx function evaluation coverage.""" + + async def test_eval_lower_upper_functions(self, mock_shared_state): + """Test Lower and Upper functions.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.text", "Hello World") + + result = await state.eval("=Lower(turn.text)") + assert result == "hello world" + + result = await state.eval("=Upper(turn.text)") + assert result == "HELLO WORLD" + + async def test_eval_isblank_function(self, mock_shared_state): + """Test IsBlank function.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.empty", "") + await state.set("turn.value", "hello") + + result = await state.eval("=IsBlank(turn.empty)") + assert result is True + + result = await state.eval("=IsBlank(turn.value)") + assert result is False + + async def test_eval_if_function(self, mock_shared_state): + """Test If function.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.flag", True) + + result = await state.eval('=If(turn.flag, "yes", "no")') + assert result == "yes" + + await state.set("turn.flag", False) + result = await state.eval('=If(turn.flag, "yes", "no")') + assert result == "no" + + async def test_eval_message_text_function(self, mock_shared_state): + """Test MessageText function.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set( + "turn.messages", [{"role": "assistant", "content": "Hello"}, {"role": "user", "content": "World"}] + ) + + result = await state.eval("=MessageText(turn.messages)") + assert "Hello" in result + assert "World" in result + + async def test_eval_count_rows_function(self, mock_shared_state): + """Test CountRows function.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.items", [1, 2, 3, 4, 5]) + + result = await state.eval("=CountRows(turn.items)") + assert result == 5 + + async def test_eval_first_last_functions(self, mock_shared_state): + """Test First and Last functions.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.items", ["a", "b", "c"]) + + result = await state.eval("=First(turn.items)") + # Accepts raw value (Python fallback) or record (pythonnet/PowerFx) + assert result == "a" or result == {"Value": "a"} + + result = await state.eval("=Last(turn.items)") + assert result == "c" or result == {"Value": "c"} + + async def test_eval_find_function(self, mock_shared_state): + """Test Find function.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.text", "hello world") + + result = await state.eval('=Find("world", turn.text)') + assert result == 7 # 1-indexed position + + async def test_eval_concat_function(self, mock_shared_state): + """Test Concat function.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.a", "Hello") + await state.set("turn.b", "World") + + result = await state.eval('=Concat(turn.a, " ", turn.b)') + assert result == "Hello World" + + async def test_eval_not_function(self, mock_shared_state): + """Test Not function.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.flag", True) + + result = await state.eval("=Not(turn.flag)") + assert result is False + + async def test_eval_and_or_functions(self, mock_shared_state): + """Test And and Or functions.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.a", True) + await state.set("turn.b", False) + + result = await state.eval("=And(turn.a, turn.b)") + assert result is False + + result = await state.eval("=Or(turn.a, turn.b)") + assert result is True + + +# --------------------------------------------------------------------------- +# Builder control flow tests - Covering Goto/Break/Continue creation +# --------------------------------------------------------------------------- + + +class TestBuilderControlFlowCreation: + """Tests for Goto, Break, Continue executor creation in builder.""" + + def test_create_goto_reference(self): + """Test creating a goto reference executor.""" + from agent_framework import WorkflowBuilder + + from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder + + # Create builder with minimal yaml definition + yaml_def = {"name": "test_workflow", "actions": []} + graph_builder = DeclarativeWorkflowBuilder(yaml_def) + wb = WorkflowBuilder() + + action_def = { + "kind": "GotoAction", + "target": "some_target_action", + "id": "goto_test", + } + + executor = graph_builder._create_goto_reference(action_def, wb, None) + + assert executor is not None + assert executor.id == "goto_test" + # Verify pending goto was recorded + assert len(graph_builder._pending_gotos) == 1 + assert graph_builder._pending_gotos[0][1] == "some_target_action" + + def test_create_goto_reference_auto_id(self): + """Test creating a goto with auto-generated ID.""" + from agent_framework import WorkflowBuilder + + from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder + + yaml_def = {"name": "test_workflow", "actions": []} + graph_builder = DeclarativeWorkflowBuilder(yaml_def) + wb = WorkflowBuilder() + + action_def = { + "kind": "GotoAction", + "target": "target_action", + } + + executor = graph_builder._create_goto_reference(action_def, wb, None) + + assert executor is not None + assert "goto_target_action" in executor.id + + def test_create_goto_reference_no_target(self): + """Test creating a goto with no target returns None.""" + from agent_framework import WorkflowBuilder + + from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder + + yaml_def = {"name": "test_workflow", "actions": []} + graph_builder = DeclarativeWorkflowBuilder(yaml_def) + wb = WorkflowBuilder() + + action_def = { + "kind": "GotoAction", + # No target specified + } + + executor = graph_builder._create_goto_reference(action_def, wb, None) + assert executor is None + + def test_goto_invalid_target_raises_error(self): + """Test that goto to non-existent target raises ValueError.""" + from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder + + yaml_def = { + "name": "test_workflow", + "actions": [ + {"kind": "SendActivity", "id": "action1", "activity": {"text": "Hello"}}, + {"kind": "GotoAction", "target": "non_existent_action"}, + ], + } + builder = DeclarativeWorkflowBuilder(yaml_def) + + with pytest.raises(ValueError) as exc_info: + builder.build() + + assert "non_existent_action" in str(exc_info.value) + assert "not found" in str(exc_info.value) + + def test_create_break_executor(self): + """Test creating a break executor within a loop context.""" + from agent_framework import WorkflowBuilder + + from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder + from agent_framework_declarative._workflows._executors_control_flow import ForeachNextExecutor + + yaml_def = {"name": "test_workflow", "actions": []} + graph_builder = DeclarativeWorkflowBuilder(yaml_def) + wb = WorkflowBuilder() + + # Create a mock loop_next executor + loop_next = ForeachNextExecutor( + {"kind": "Foreach", "itemsProperty": "items"}, + init_executor_id="foreach_init", + id="foreach_next", + ) + wb._add_executor(loop_next) + + parent_context = {"loop_next_executor": loop_next} + + action_def = { + "kind": "BreakLoop", + "id": "break_test", + } + + executor = graph_builder._create_break_executor(action_def, wb, parent_context) + + assert executor is not None + assert executor.id == "break_test" + + def test_create_break_executor_no_loop_context(self): + """Test creating a break executor without loop context raises ValueError.""" + from agent_framework import WorkflowBuilder + + from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder + + yaml_def = {"name": "test_workflow", "actions": []} + graph_builder = DeclarativeWorkflowBuilder(yaml_def) + wb = WorkflowBuilder() + + action_def = { + "kind": "BreakLoop", + } + + # No parent_context should raise ValueError + with pytest.raises(ValueError) as exc_info: + graph_builder._create_break_executor(action_def, wb, None) + assert "BreakLoop action can only be used inside a Foreach loop" in str(exc_info.value) + + # Empty context should also raise ValueError + with pytest.raises(ValueError) as exc_info: + graph_builder._create_break_executor(action_def, wb, {}) + assert "BreakLoop action can only be used inside a Foreach loop" in str(exc_info.value) + + def test_create_continue_executor(self): + """Test creating a continue executor within a loop context.""" + from agent_framework import WorkflowBuilder + + from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder + from agent_framework_declarative._workflows._executors_control_flow import ForeachNextExecutor + + yaml_def = {"name": "test_workflow", "actions": []} + graph_builder = DeclarativeWorkflowBuilder(yaml_def) + wb = WorkflowBuilder() + + # Create a mock loop_next executor + loop_next = ForeachNextExecutor( + {"kind": "Foreach", "itemsProperty": "items"}, + init_executor_id="foreach_init", + id="foreach_next", + ) + wb._add_executor(loop_next) + + parent_context = {"loop_next_executor": loop_next} + + action_def = { + "kind": "ContinueLoop", + "id": "continue_test", + } + + executor = graph_builder._create_continue_executor(action_def, wb, parent_context) + + assert executor is not None + assert executor.id == "continue_test" + + def test_create_continue_executor_no_loop_context(self): + """Test creating a continue executor without loop context raises ValueError.""" + from agent_framework import WorkflowBuilder + + from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder + + yaml_def = {"name": "test_workflow", "actions": []} + graph_builder = DeclarativeWorkflowBuilder(yaml_def) + wb = WorkflowBuilder() + + action_def = { + "kind": "ContinueLoop", + } + + # No parent_context should raise ValueError + with pytest.raises(ValueError) as exc_info: + graph_builder._create_continue_executor(action_def, wb, None) + assert "ContinueLoop action can only be used inside a Foreach loop" in str(exc_info.value) + + +class TestBuilderEdgeWiring: + """Tests for builder edge wiring methods.""" + + def test_wire_to_target_with_if_structure(self): + """Test wiring to an If structure routes to evaluator.""" + from agent_framework import WorkflowBuilder + + from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder + from agent_framework_declarative._workflows._executors_basic import SendActivityExecutor + + yaml_def = {"name": "test_workflow", "actions": []} + graph_builder = DeclarativeWorkflowBuilder(yaml_def) + wb = WorkflowBuilder() + + # Create a mock source executor + source = SendActivityExecutor({"kind": "SendActivity", "activity": {"text": "test"}}, id="source") + wb._add_executor(source) + + # Create a mock If structure with evaluator + class MockIfStructure: + _is_if_structure = True + + def __init__(self): + self.evaluator = SendActivityExecutor( + {"kind": "SendActivity", "activity": {"text": "evaluator"}}, id="evaluator" + ) + + target = MockIfStructure() + wb._add_executor(target.evaluator) + + # Wire should add edge to evaluator + graph_builder._wire_to_target(wb, source, target) + + # Verify edge was added (would need to inspect workflow internals) + # For now, just verify no exception was raised + + def test_wire_to_target_normal_executor(self): + """Test wiring to a normal executor adds direct edge.""" + from agent_framework import WorkflowBuilder + + from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder + from agent_framework_declarative._workflows._executors_basic import SendActivityExecutor + + yaml_def = {"name": "test_workflow", "actions": []} + graph_builder = DeclarativeWorkflowBuilder(yaml_def) + wb = WorkflowBuilder() + + source = SendActivityExecutor({"kind": "SendActivity", "activity": {"text": "source"}}, id="source") + target = SendActivityExecutor({"kind": "SendActivity", "activity": {"text": "target"}}, id="target") + + wb._add_executor(source) + wb._add_executor(target) + + graph_builder._wire_to_target(wb, source, target) + # Verify edge creation (no exception = success) + + def test_collect_all_exits_for_nested_structure(self): + """Test collecting all exits from nested structures.""" + from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder + from agent_framework_declarative._workflows._executors_basic import SendActivityExecutor + + yaml_def = {"name": "test_workflow", "actions": []} + graph_builder = DeclarativeWorkflowBuilder(yaml_def) + + # Create mock nested structure + exit1 = SendActivityExecutor({"kind": "SendActivity", "activity": {"text": "exit1"}}, id="exit1") + exit2 = SendActivityExecutor({"kind": "SendActivity", "activity": {"text": "exit2"}}, id="exit2") + + class InnerStructure: + def __init__(self): + self.branch_exits = [exit1, exit2] + + class OuterStructure: + def __init__(self): + self.branch_exits = [InnerStructure()] + + outer = OuterStructure() + exits = graph_builder._collect_all_exits(outer) + + assert len(exits) == 2 + assert exit1 in exits + assert exit2 in exits + + def test_collect_all_exits_for_simple_executor(self): + """Test collecting exits from a simple executor.""" + from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder + from agent_framework_declarative._workflows._executors_basic import SendActivityExecutor + + yaml_def = {"name": "test_workflow", "actions": []} + graph_builder = DeclarativeWorkflowBuilder(yaml_def) + + executor = SendActivityExecutor({"kind": "SendActivity", "activity": {"text": "test"}}, id="test") + + exits = graph_builder._collect_all_exits(executor) + + assert len(exits) == 1 + assert executor in exits + + def test_get_branch_exit_with_chain(self): + """Test getting branch exit from a chain of executors.""" + from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder + from agent_framework_declarative._workflows._executors_basic import SendActivityExecutor + + yaml_def = {"name": "test_workflow", "actions": []} + graph_builder = DeclarativeWorkflowBuilder(yaml_def) + + exec1 = SendActivityExecutor({"kind": "SendActivity", "activity": {"text": "1"}}, id="e1") + exec2 = SendActivityExecutor({"kind": "SendActivity", "activity": {"text": "2"}}, id="e2") + exec3 = SendActivityExecutor({"kind": "SendActivity", "activity": {"text": "3"}}, id="e3") + + # Simulate a chain by dynamically setting attribute + exec1._chain_executors = [exec1, exec2, exec3] # type: ignore[attr-defined] + + exit_exec = graph_builder._get_branch_exit(exec1) + + assert exit_exec == exec3 + + def test_get_branch_exit_none(self): + """Test getting branch exit from None.""" + from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder + + yaml_def = {"name": "test_workflow", "actions": []} + graph_builder = DeclarativeWorkflowBuilder(yaml_def) + + exit_exec = graph_builder._get_branch_exit(None) + assert exit_exec is None + + +# --------------------------------------------------------------------------- +# Agent executor external loop response handler tests +# --------------------------------------------------------------------------- + + +class TestAgentExecutorExternalLoop: + """Tests for InvokeAzureAgentExecutor external loop response handling.""" + + async def test_handle_external_input_response_no_state(self, mock_context, mock_shared_state): + """Test handling external input response when loop state not found.""" + from agent_framework_declarative._workflows._executors_agents import ( + ExternalInputRequest, + ExternalInputResponse, + InvokeAzureAgentExecutor, + ) + + executor = InvokeAzureAgentExecutor({"kind": "InvokeAzureAgent", "agent": "TestAgent"}) + + # No external loop state in shared_state + original_request = ExternalInputRequest( + request_id="req-1", + agent_name="TestAgent", + agent_response="Hello", + iteration=1, + ) + response = ExternalInputResponse(user_input="hi there") + + await executor.handle_external_input_response(original_request, response, mock_context) + + # Should send ActionComplete due to missing state + mock_context.send_message.assert_called() + call_args = mock_context.send_message.call_args[0][0] + from agent_framework_declarative._workflows import ActionComplete + + assert isinstance(call_args, ActionComplete) + + async def test_handle_external_input_response_agent_not_found(self, mock_context, mock_shared_state): + """Test handling external input raises error when agent not found during resumption.""" + from agent_framework_declarative._workflows._executors_agents import ( + EXTERNAL_LOOP_STATE_KEY, + AgentInvocationError, + ExternalInputRequest, + ExternalInputResponse, + ExternalLoopState, + InvokeAzureAgentExecutor, + ) + + # Set up loop state with always true condition (literal) + loop_state = ExternalLoopState( + agent_name="NonExistentAgent", + iteration=1, + external_loop_when="true", # Literal true + messages_var=None, + response_obj_var=None, + result_property=None, + auto_send=True, + messages_path="conversation.messages", + ) + mock_shared_state._data[EXTERNAL_LOOP_STATE_KEY] = loop_state + + # Initialize declarative state with simple value + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + executor = InvokeAzureAgentExecutor({"kind": "InvokeAzureAgent", "agent": "NonExistentAgent"}) + + original_request = ExternalInputRequest( + request_id="req-1", + agent_name="NonExistentAgent", + agent_response="Hello", + iteration=1, + ) + response = ExternalInputResponse(user_input="continue") + + with pytest.raises(AgentInvocationError) as exc_info: + await executor.handle_external_input_response(original_request, response, mock_context) + + assert "NonExistentAgent" in str(exc_info.value) + assert "not found during loop resumption" in str(exc_info.value) + + +class TestBuilderValidation: + """Tests for builder validation features (P1 fixes).""" + + def test_duplicate_explicit_action_id_raises_error(self): + """Test that duplicate explicit action IDs are detected.""" + from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder + + yaml_def = { + "name": "test_workflow", + "actions": [ + {"id": "my_action", "kind": "SendActivity", "activity": {"text": "First"}}, + {"id": "my_action", "kind": "SendActivity", "activity": {"text": "Second"}}, + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def) + with pytest.raises(ValueError) as exc_info: + builder.build() + + assert "Duplicate action ID 'my_action'" in str(exc_info.value) + + def test_duplicate_id_in_nested_actions(self): + """Test duplicate ID detection in nested If/Switch branches.""" + from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder + + yaml_def = { + "name": "test_workflow", + "actions": [ + { + "kind": "If", + "condition": "=true", + "then": [{"id": "shared_id", "kind": "SendActivity", "activity": {"text": "Then"}}], + "else": [{"id": "shared_id", "kind": "SendActivity", "activity": {"text": "Else"}}], + } + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def) + with pytest.raises(ValueError) as exc_info: + builder.build() + + assert "Duplicate action ID 'shared_id'" in str(exc_info.value) + + def test_missing_required_field_sendactivity(self): + """Test that missing required fields are detected.""" + from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder + + yaml_def = { + "name": "test_workflow", + "actions": [{"kind": "SendActivity"}], # Missing 'activity' field + } + + builder = DeclarativeWorkflowBuilder(yaml_def) + with pytest.raises(ValueError) as exc_info: + builder.build() + + assert "SendActivity" in str(exc_info.value) + assert "missing required field" in str(exc_info.value) + assert "activity" in str(exc_info.value) + + def test_missing_required_field_setvalue(self): + """Test SetValue without path raises error.""" + from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder + + yaml_def = { + "name": "test_workflow", + "actions": [{"kind": "SetValue", "value": "test"}], # Missing 'path' field + } + + builder = DeclarativeWorkflowBuilder(yaml_def) + with pytest.raises(ValueError) as exc_info: + builder.build() + + assert "SetValue" in str(exc_info.value) + assert "path" in str(exc_info.value) + + def test_setvalue_accepts_alternate_variable_field(self): + """Test SetValue accepts 'variable' as alternate to 'path'.""" + from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder + + yaml_def = { + "name": "test_workflow", + "actions": [{"kind": "SetValue", "variable": {"path": "turn.x"}, "value": "test"}], + } + + builder = DeclarativeWorkflowBuilder(yaml_def) + # Should not raise - 'variable' is accepted as alternate + workflow = builder.build() + assert workflow is not None + + def test_missing_required_field_foreach(self): + """Test Foreach without items raises error.""" + from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder + + yaml_def = { + "name": "test_workflow", + "actions": [{"kind": "Foreach", "actions": [{"kind": "SendActivity", "activity": {"text": "Hi"}}]}], + } + + builder = DeclarativeWorkflowBuilder(yaml_def) + with pytest.raises(ValueError) as exc_info: + builder.build() + + assert "Foreach" in str(exc_info.value) + assert "items" in str(exc_info.value) + + def test_self_referencing_goto_raises_error(self): + """Test that a goto referencing itself is detected.""" + from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder + + yaml_def = { + "name": "test_workflow", + "actions": [{"id": "loop", "kind": "Goto", "target": "loop"}], + } + + builder = DeclarativeWorkflowBuilder(yaml_def) + with pytest.raises(ValueError) as exc_info: + builder.build() + + assert "loop" in str(exc_info.value) + assert "self-referencing" in str(exc_info.value) + + def test_validation_can_be_disabled(self): + """Test that validation can be disabled for early schema/duplicate checks. + + Note: Even with validation disabled, the underlying WorkflowBuilder may + still catch duplicates during graph construction. This flag disables + our upfront validation pass but not runtime checks. + """ + from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder + + # Test with missing required field - validation disabled should skip our check + yaml_def = { + "name": "test_workflow", + "actions": [{"kind": "SendActivity"}], # Missing 'activity' - normally caught by validation + } + + # With validation disabled, our upfront check is skipped + builder = DeclarativeWorkflowBuilder(yaml_def, validate=False) + # The workflow may still fail for other reasons, but our validation pass is skipped + # In this case, it should succeed because SendActivityExecutor handles missing fields gracefully + workflow = builder.build() + assert workflow is not None + + def test_validation_in_switch_branches(self): + """Test validation catches issues in Switch branches.""" + from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder + + yaml_def = { + "name": "test_workflow", + "actions": [ + { + "kind": "Switch", + "value": "=turn.choice", + "cases": [ + { + "match": "a", + "actions": [{"id": "dup", "kind": "SendActivity", "activity": {"text": "A"}}], + }, + { + "match": "b", + "actions": [{"id": "dup", "kind": "SendActivity", "activity": {"text": "B"}}], + }, + ], + } + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def) + with pytest.raises(ValueError) as exc_info: + builder.build() + + assert "Duplicate action ID 'dup'" in str(exc_info.value) + + def test_validation_in_foreach_body(self): + """Test validation catches issues in Foreach body.""" + from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder + + yaml_def = { + "name": "test_workflow", + "actions": [ + { + "kind": "Foreach", + "items": "=turn.items", + "actions": [{"kind": "SendActivity"}], # Missing 'activity' + } + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def) + with pytest.raises(ValueError) as exc_info: + builder.build() + + assert "SendActivity" in str(exc_info.value) + assert "activity" in str(exc_info.value) + + +class TestExpressionEdgeCases: + """Tests for expression evaluation edge cases (P1 fixes).""" + + async def test_division_by_zero_returns_none(self, mock_shared_state): + """Test that division by zero returns None (Blank).""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.x", 10) + await state.set("turn.y", 0) + + result = await state.eval("=turn.x / turn.y") + assert result is None # PowerFx returns Blank/Error for div by zero + + async def test_division_with_valid_values(self, mock_shared_state): + """Test normal division works correctly.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.x", 10) + await state.set("turn.y", 4) + + result = await state.eval("=turn.x / turn.y") + assert result == 2.5 + + async def test_multiplication_with_none_as_zero(self, mock_shared_state): + """Test multiplication treats None as 0.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.x", 5) + # turn.y is not set + + result = await state.eval("=turn.x * turn.y") + assert result == 0 # 5 * 0 = 0 + + async def test_multiplication_normal(self, mock_shared_state): + """Test normal multiplication.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.x", 6) + await state.set("turn.y", 7) + + result = await state.eval("=turn.x * turn.y") + assert result == 42 + + async def test_division_with_none_numerator(self, mock_shared_state): + """Test division with None numerator returns 0.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.y", 5) + # turn.x is not set (None) + + result = await state.eval("=turn.x / turn.y") + assert result == 0 # 0 / 5 = 0 + + async def test_division_both_none_returns_none(self, mock_shared_state): + """Test division with both operands None returns None (div by zero).""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + # Neither turn.x nor turn.y are set + + result = await state.eval("=turn.x / turn.y") + assert result is None # 0 / 0 = Blank diff --git a/python/packages/declarative/tests/test_graph_executors.py b/python/packages/declarative/tests/test_graph_executors.py new file mode 100644 index 0000000000..7d1c1640a3 --- /dev/null +++ b/python/packages/declarative/tests/test_graph_executors.py @@ -0,0 +1,533 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for the graph-based declarative workflow executors.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from agent_framework_declarative._workflows import ( + ALL_ACTION_EXECUTORS, + DECLARATIVE_STATE_KEY, + ActionComplete, + ActionTrigger, + DeclarativeWorkflowBuilder, + DeclarativeWorkflowState, + ForeachInitExecutor, + LoopIterationResult, + SendActivityExecutor, + SetValueExecutor, +) + + +class TestDeclarativeWorkflowState: + """Tests for DeclarativeWorkflowState.""" + + @pytest.fixture + def mock_shared_state(self): + """Create a mock shared state with async get/set methods.""" + shared_state = MagicMock() + shared_state._data = {} + + async def mock_get(key): + if key not in shared_state._data: + raise KeyError(key) + return shared_state._data[key] + + async def mock_set(key, value): + shared_state._data[key] = value + + shared_state.get = AsyncMock(side_effect=mock_get) + shared_state.set = AsyncMock(side_effect=mock_set) + + return shared_state + + @pytest.mark.asyncio + async def test_initialize_state(self, mock_shared_state): + """Test initializing the workflow state.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize({"query": "test"}) + + # Verify state was set + mock_shared_state.set.assert_called_once() + call_args = mock_shared_state.set.call_args + assert call_args[0][0] == DECLARATIVE_STATE_KEY + state_data = call_args[0][1] + assert state_data["inputs"] == {"query": "test"} + assert state_data["outputs"] == {} + assert state_data["turn"] == {} + + @pytest.mark.asyncio + async def test_get_and_set_values(self, mock_shared_state): + """Test getting and setting values.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + # Set a turn value + await state.set("turn.counter", 5) + + # Get the value + result = await state.get("turn.counter") + assert result == 5 + + @pytest.mark.asyncio + async def test_get_inputs(self, mock_shared_state): + """Test getting workflow inputs.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize({"name": "Alice", "age": 30}) + + # Get via path + name = await state.get("workflow.inputs.name") + assert name == "Alice" + + # Get all inputs + inputs = await state.get_inputs() + assert inputs == {"name": "Alice", "age": 30} + + @pytest.mark.asyncio + async def test_append_value(self, mock_shared_state): + """Test appending values to a list.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + # Append to non-existent list creates it + await state.append("turn.items", "first") + result = await state.get("turn.items") + assert result == ["first"] + + # Append to existing list + await state.append("turn.items", "second") + result = await state.get("turn.items") + assert result == ["first", "second"] + + @pytest.mark.asyncio + async def test_eval_expression(self, mock_shared_state): + """Test evaluating expressions.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + # Non-expression returns as-is + result = await state.eval("plain text") + assert result == "plain text" + + # Boolean literals + result = await state.eval("=true") + assert result is True + + result = await state.eval("=false") + assert result is False + + # String literals + result = await state.eval('="hello"') + assert result == "hello" + + # Numeric literals + result = await state.eval("=42") + assert result == 42 + + +class TestDeclarativeActionExecutor: + """Tests for DeclarativeActionExecutor subclasses.""" + + @pytest.fixture + def mock_context(self, mock_shared_state): + """Create a mock workflow context.""" + ctx = MagicMock() + ctx.shared_state = mock_shared_state + ctx.send_message = AsyncMock() + ctx.yield_output = AsyncMock() + return ctx + + @pytest.fixture + def mock_shared_state(self): + """Create a mock shared state.""" + shared_state = MagicMock() + shared_state._data = {} + + async def mock_get(key): + if key not in shared_state._data: + raise KeyError(key) + return shared_state._data[key] + + async def mock_set(key, value): + shared_state._data[key] = value + + shared_state.get = AsyncMock(side_effect=mock_get) + shared_state.set = AsyncMock(side_effect=mock_set) + + return shared_state + + @pytest.mark.asyncio + async def test_set_value_executor(self, mock_context, mock_shared_state): + """Test SetValueExecutor.""" + # Initialize state + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + action_def = { + "kind": "SetValue", + "path": "turn.result", + "value": "test value", + } + executor = SetValueExecutor(action_def) + + # Execute + await executor.handle_action(ActionTrigger(), mock_context) + + # Verify action complete was sent + mock_context.send_message.assert_called_once() + message = mock_context.send_message.call_args[0][0] + assert isinstance(message, ActionComplete) + + @pytest.mark.asyncio + async def test_send_activity_executor(self, mock_context, mock_shared_state): + """Test SendActivityExecutor.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + action_def = { + "kind": "SendActivity", + "activity": {"text": "Hello, world!"}, + } + executor = SendActivityExecutor(action_def) + + # Execute + await executor.handle_action(ActionTrigger(), mock_context) + + # Verify output was yielded + mock_context.yield_output.assert_called_once_with("Hello, world!") + + # Note: ConditionEvaluatorExecutor tests removed - conditions are now evaluated on edges + + async def test_foreach_init_with_items(self, mock_context, mock_shared_state): + """Test ForeachInitExecutor with items.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + await state.set("turn.items", ["a", "b", "c"]) + + action_def = { + "kind": "Foreach", + "itemsSource": "=turn.items", + "iteratorVariable": "turn.item", + } + executor = ForeachInitExecutor(action_def) + + # Execute + await executor.handle_action(ActionTrigger(), mock_context) + + # Verify result + mock_context.send_message.assert_called_once() + message = mock_context.send_message.call_args[0][0] + assert isinstance(message, LoopIterationResult) + assert message.has_next is True + assert message.current_index == 0 + assert message.current_item == "a" + + @pytest.mark.asyncio + async def test_foreach_init_empty(self, mock_context, mock_shared_state): + """Test ForeachInitExecutor with empty items.""" + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + action_def = { + "kind": "Foreach", + "itemsSource": "=turn.empty", + "iteratorVariable": "turn.item", + } + executor = ForeachInitExecutor(action_def) + + # Execute + await executor.handle_action(ActionTrigger(), mock_context) + + # Verify result + mock_context.send_message.assert_called_once() + message = mock_context.send_message.call_args[0][0] + assert isinstance(message, LoopIterationResult) + assert message.has_next is False + + +class TestDeclarativeWorkflowBuilder: + """Tests for DeclarativeWorkflowBuilder.""" + + def test_all_action_executors_available(self): + """Test that all expected action types have executors.""" + expected_actions = [ + "SetValue", + "SetVariable", + "SendActivity", + "EmitEvent", + "EndWorkflow", + "InvokeAzureAgent", + "Question", + ] + + for action in expected_actions: + assert action in ALL_ACTION_EXECUTORS, f"Missing executor for {action}" + + def test_build_empty_workflow(self): + """Test building a workflow with no actions raises an error.""" + yaml_def = {"name": "empty_workflow", "actions": []} + builder = DeclarativeWorkflowBuilder(yaml_def) + + with pytest.raises(ValueError, match="Cannot build workflow with no actions"): + builder.build() + + def test_build_simple_workflow(self): + """Test building a workflow with simple sequential actions.""" + yaml_def = { + "name": "simple_workflow", + "actions": [ + {"kind": "SendActivity", "id": "greet", "activity": {"text": "Hello!"}}, + {"kind": "SetValue", "id": "set_count", "path": "turn.count", "value": 1}, + ], + } + builder = DeclarativeWorkflowBuilder(yaml_def) + workflow = builder.build() + + assert workflow is not None + # Verify executors were created + assert "greet" in builder._executors + assert "set_count" in builder._executors + + def test_build_workflow_with_if(self): + """Test building a workflow with If control flow.""" + yaml_def = { + "name": "conditional_workflow", + "actions": [ + { + "kind": "If", + "id": "check_flag", + "condition": "=turn.flag", + "then": [ + {"kind": "SendActivity", "id": "say_yes", "activity": {"text": "Yes!"}}, + ], + "else": [ + {"kind": "SendActivity", "id": "say_no", "activity": {"text": "No!"}}, + ], + }, + ], + } + builder = DeclarativeWorkflowBuilder(yaml_def) + workflow = builder.build() + + assert workflow is not None + # Verify branch executors were created + # Note: No join executors - branches wire directly to successor + assert "say_yes" in builder._executors + assert "say_no" in builder._executors + # Entry node is created when If is first action + assert "_workflow_entry" in builder._executors + + def test_build_workflow_with_foreach(self): + """Test building a workflow with Foreach loop.""" + yaml_def = { + "name": "loop_workflow", + "actions": [ + { + "kind": "Foreach", + "id": "process_items", + "itemsSource": "=turn.items", + "iteratorVariable": "turn.item", + "actions": [ + {"kind": "SendActivity", "id": "show_item", "activity": {"text": "=turn.item"}}, + ], + }, + ], + } + builder = DeclarativeWorkflowBuilder(yaml_def) + workflow = builder.build() + + assert workflow is not None + # Verify loop executors were created + assert "process_items_init" in builder._executors + assert "process_items_next" in builder._executors + assert "process_items_exit" in builder._executors + assert "show_item" in builder._executors + + def test_build_workflow_with_switch(self): + """Test building a workflow with Switch control flow.""" + yaml_def = { + "name": "switch_workflow", + "actions": [ + { + "kind": "Switch", + "id": "check_status", + "conditions": [ + { + "condition": '=turn.status = "active"', + "actions": [ + {"kind": "SendActivity", "id": "say_active", "activity": {"text": "Active"}}, + ], + }, + { + "condition": '=turn.status = "pending"', + "actions": [ + {"kind": "SendActivity", "id": "say_pending", "activity": {"text": "Pending"}}, + ], + }, + ], + "else": [ + {"kind": "SendActivity", "id": "say_unknown", "activity": {"text": "Unknown"}}, + ], + }, + ], + } + builder = DeclarativeWorkflowBuilder(yaml_def) + workflow = builder.build() + + assert workflow is not None + # Verify switch executors were created + # Note: No join executors - branches wire directly to successor + assert "say_active" in builder._executors + assert "say_pending" in builder._executors + assert "say_unknown" in builder._executors + # Entry node is created when Switch is first action + assert "_workflow_entry" in builder._executors + + +class TestAgentExecutors: + """Tests for agent-related executors.""" + + @pytest.fixture + def mock_context(self, mock_shared_state): + """Create a mock workflow context.""" + ctx = MagicMock() + ctx.shared_state = mock_shared_state + ctx.send_message = AsyncMock() + ctx.yield_output = AsyncMock() + return ctx + + @pytest.fixture + def mock_shared_state(self): + """Create a mock shared state.""" + shared_state = MagicMock() + shared_state._data = {} + + async def mock_get(key): + if key not in shared_state._data: + raise KeyError(key) + return shared_state._data[key] + + async def mock_set(key, value): + shared_state._data[key] = value + + shared_state.get = AsyncMock(side_effect=mock_get) + shared_state.set = AsyncMock(side_effect=mock_set) + + return shared_state + + @pytest.mark.asyncio + async def test_invoke_agent_not_found(self, mock_context, mock_shared_state): + """Test InvokeAzureAgentExecutor raises error when agent not found.""" + from agent_framework_declarative._workflows import ( + AgentInvocationError, + InvokeAzureAgentExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + action_def = { + "kind": "InvokeAzureAgent", + "agent": "non_existent_agent", + "input": "test input", + } + executor = InvokeAzureAgentExecutor(action_def) + + # Execute - should raise AgentInvocationError + with pytest.raises(AgentInvocationError) as exc_info: + await executor.handle_action(ActionTrigger(), mock_context) + + assert "non_existent_agent" in str(exc_info.value) + assert "not found in registry" in str(exc_info.value) + + +class TestHumanInputExecutors: + """Tests for human input executors.""" + + @pytest.fixture + def mock_context(self, mock_shared_state): + """Create a mock workflow context.""" + ctx = MagicMock() + ctx.shared_state = mock_shared_state + ctx.send_message = AsyncMock() + ctx.yield_output = AsyncMock() + return ctx + + @pytest.fixture + def mock_shared_state(self): + """Create a mock shared state.""" + shared_state = MagicMock() + shared_state._data = {} + + async def mock_get(key): + if key not in shared_state._data: + raise KeyError(key) + return shared_state._data[key] + + async def mock_set(key, value): + shared_state._data[key] = value + + shared_state.get = AsyncMock(side_effect=mock_get) + shared_state.set = AsyncMock(side_effect=mock_set) + + return shared_state + + @pytest.mark.asyncio + async def test_question_executor(self, mock_context, mock_shared_state): + """Test QuestionExecutor.""" + from agent_framework_declarative._workflows import ( + HumanInputRequest, + QuestionExecutor, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + action_def = { + "kind": "Question", + "text": "What is your name?", + "property": "turn.name", + "defaultValue": "Anonymous", + } + executor = QuestionExecutor(action_def) + + # Execute + await executor.handle_action(ActionTrigger(), mock_context) + + # Verify human input request was yielded + assert mock_context.yield_output.called + request = mock_context.yield_output.call_args_list[0][0][0] + assert isinstance(request, HumanInputRequest) + assert request.request_type == "question" + assert "What is your name?" in request.message + + @pytest.mark.asyncio + async def test_confirmation_executor(self, mock_context, mock_shared_state): + """Test ConfirmationExecutor.""" + from agent_framework_declarative._workflows import ( + ConfirmationExecutor, + HumanInputRequest, + ) + + state = DeclarativeWorkflowState(mock_shared_state) + await state.initialize() + + action_def = { + "kind": "Confirmation", + "text": "Do you want to continue?", + "property": "turn.confirmed", + "yesLabel": "Yes, continue", + "noLabel": "No, stop", + } + executor = ConfirmationExecutor(action_def) + + # Execute + await executor.handle_action(ActionTrigger(), mock_context) + + # Verify confirmation request was yielded + assert mock_context.yield_output.called + request = mock_context.yield_output.call_args_list[0][0][0] + assert isinstance(request, HumanInputRequest) + assert request.request_type == "confirmation" + assert "continue" in request.message.lower() diff --git a/python/packages/declarative/tests/test_graph_workflow_integration.py b/python/packages/declarative/tests/test_graph_workflow_integration.py new file mode 100644 index 0000000000..a623d05d32 --- /dev/null +++ b/python/packages/declarative/tests/test_graph_workflow_integration.py @@ -0,0 +1,332 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Integration tests for declarative workflows. + +These tests verify: +- End-to-end workflow execution +- Checkpointing at action boundaries +- WorkflowFactory creating graph-based workflows +- Pause/resume capabilities +""" + +import pytest + +from agent_framework_declarative._workflows import ( + ActionTrigger, + DeclarativeWorkflowBuilder, +) +from agent_framework_declarative._workflows._factory import WorkflowFactory + + +class TestGraphBasedWorkflowExecution: + """Integration tests for graph-based workflow execution.""" + + @pytest.mark.asyncio + async def test_simple_sequential_workflow(self): + """Test a simple sequential workflow with SendActivity actions.""" + yaml_def = { + "name": "simple_workflow", + "actions": [ + {"kind": "SendActivity", "id": "greet", "activity": {"text": "Hello!"}}, + {"kind": "SetValue", "id": "set_count", "path": "turn.count", "value": 1}, + {"kind": "SendActivity", "id": "done", "activity": {"text": "Done!"}}, + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def) + workflow = builder.build() + + # Run the workflow + events = await workflow.run(ActionTrigger()) + + # Verify outputs were produced + outputs = events.get_outputs() + assert "Hello!" in outputs + assert "Done!" in outputs + + @pytest.mark.asyncio + async def test_workflow_with_conditional(self): + """Test workflow with If conditional branching.""" + yaml_def = { + "name": "conditional_workflow", + "actions": [ + {"kind": "SetValue", "id": "set_flag", "path": "turn.flag", "value": True}, + { + "kind": "If", + "id": "check_flag", + "condition": "=turn.flag", + "then": [ + {"kind": "SendActivity", "id": "say_yes", "activity": {"text": "Flag is true!"}}, + ], + "else": [ + {"kind": "SendActivity", "id": "say_no", "activity": {"text": "Flag is false!"}}, + ], + }, + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def) + workflow = builder.build() + + # Run the workflow + events = await workflow.run(ActionTrigger()) + outputs = events.get_outputs() + + # Should take the "then" branch since flag is True + assert "Flag is true!" in outputs + assert "Flag is false!" not in outputs + + @pytest.mark.asyncio + async def test_workflow_with_foreach_loop(self): + """Test workflow with Foreach loop.""" + yaml_def = { + "name": "loop_workflow", + "actions": [ + {"kind": "SetValue", "id": "set_items", "path": "turn.items", "value": ["a", "b", "c"]}, + { + "kind": "Foreach", + "id": "process_items", + "itemsSource": "=turn.items", + "iteratorVariable": "turn.item", + "actions": [ + {"kind": "SendActivity", "id": "show_item", "activity": {"text": "=turn.item"}}, + ], + }, + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def) + workflow = builder.build() + + # Run the workflow + events = await workflow.run(ActionTrigger()) + outputs = events.get_outputs() + + # Should output each item + assert "a" in outputs + assert "b" in outputs + assert "c" in outputs + + @pytest.mark.asyncio + async def test_workflow_with_switch(self): + """Test workflow with Switch/ConditionGroup.""" + yaml_def = { + "name": "switch_workflow", + "actions": [ + {"kind": "SetValue", "id": "set_level", "path": "turn.level", "value": 2}, + { + "kind": "Switch", + "id": "check_level", + "conditions": [ + { + "condition": "=turn.level == 1", + "actions": [ + {"kind": "SendActivity", "id": "level_1", "activity": {"text": "Level 1"}}, + ], + }, + { + "condition": "=turn.level == 2", + "actions": [ + {"kind": "SendActivity", "id": "level_2", "activity": {"text": "Level 2"}}, + ], + }, + ], + "else": [ + {"kind": "SendActivity", "id": "default", "activity": {"text": "Other level"}}, + ], + }, + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def) + workflow = builder.build() + + # Run the workflow + events = await workflow.run(ActionTrigger()) + outputs = events.get_outputs() + + # Should take the level 2 branch + assert "Level 2" in outputs + assert "Level 1" not in outputs + assert "Other level" not in outputs + + +class TestWorkflowFactory: + """Tests for WorkflowFactory.""" + + def test_factory_creates_workflow(self): + """Test creating workflow.""" + factory = WorkflowFactory() + + yaml_content = """ +name: test_workflow +actions: + - kind: SendActivity + id: greet + activity: + text: "Hello from graph mode!" + - kind: SetValue + id: set_val + path: turn.result + value: 42 +""" + workflow = factory.create_workflow_from_yaml(yaml_content) + + assert workflow is not None + assert hasattr(workflow, "_declarative_agents") + + @pytest.mark.asyncio + async def test_workflow_execution(self): + """Test executing a workflow.""" + factory = WorkflowFactory() + + yaml_content = """ +name: graph_execution_test +actions: + - kind: SendActivity + id: start + activity: + text: "Starting workflow" + - kind: SetValue + id: set_message + path: turn.message + value: "Hello World" + - kind: SendActivity + id: end + activity: + text: "Workflow complete" +""" + workflow = factory.create_workflow_from_yaml(yaml_content) + + # Execute the workflow + events = await workflow.run(ActionTrigger()) + outputs = events.get_outputs() + + assert "Starting workflow" in outputs + assert "Workflow complete" in outputs + + +class TestGraphWorkflowCheckpointing: + """Tests for checkpointing capabilities of graph-based workflows.""" + + def test_workflow_has_multiple_executors(self): + """Test that graph-based workflow creates multiple executor nodes.""" + yaml_def = { + "name": "multi_executor_workflow", + "actions": [ + {"kind": "SetValue", "id": "step1", "path": "turn.a", "value": 1}, + {"kind": "SetValue", "id": "step2", "path": "turn.b", "value": 2}, + {"kind": "SetValue", "id": "step3", "path": "turn.c", "value": 3}, + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def) + _workflow = builder.build() # noqa: F841 + + # Verify multiple executors were created + assert "step1" in builder._executors + assert "step2" in builder._executors + assert "step3" in builder._executors + assert len(builder._executors) == 3 + + def test_workflow_executor_connectivity(self): + """Test that executors are properly connected in sequence.""" + yaml_def = { + "name": "connected_workflow", + "actions": [ + {"kind": "SendActivity", "id": "a", "activity": {"text": "A"}}, + {"kind": "SendActivity", "id": "b", "activity": {"text": "B"}}, + {"kind": "SendActivity", "id": "c", "activity": {"text": "C"}}, + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def) + workflow = builder.build() + + # Verify all executors exist + assert len(builder._executors) == 3 + + # Verify the workflow can be inspected + assert workflow is not None + + +class TestGraphWorkflowVisualization: + """Tests for workflow visualization capabilities.""" + + def test_workflow_can_be_built(self): + """Test that complex workflows can be built successfully.""" + yaml_def = { + "name": "complex_workflow", + "actions": [ + {"kind": "SendActivity", "id": "intro", "activity": {"text": "Starting"}}, + { + "kind": "If", + "id": "branch", + "condition": "=true", + "then": [ + {"kind": "SendActivity", "id": "then_msg", "activity": {"text": "Then branch"}}, + ], + "else": [ + {"kind": "SendActivity", "id": "else_msg", "activity": {"text": "Else branch"}}, + ], + }, + {"kind": "SendActivity", "id": "outro", "activity": {"text": "Done"}}, + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def) + workflow = builder.build() + + # Verify the workflow was built + assert workflow is not None + + # Verify expected executors exist + # intro, branch_condition, then_msg, else_msg, branch_join, outro + assert "intro" in builder._executors + assert "then_msg" in builder._executors + assert "else_msg" in builder._executors + assert "outro" in builder._executors + + +class TestGraphWorkflowStateManagement: + """Tests for state management across graph executor nodes.""" + + @pytest.mark.asyncio + async def test_state_persists_across_executors(self): + """Test that state set in one executor is available in the next.""" + yaml_def = { + "name": "state_test", + "actions": [ + {"kind": "SetValue", "id": "set", "path": "turn.value", "value": "test_data"}, + {"kind": "SendActivity", "id": "send", "activity": {"text": "=turn.value"}}, + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def) + workflow = builder.build() + + events = await workflow.run(ActionTrigger()) + outputs = events.get_outputs() + + # The SendActivity should have access to the value set by SetValue + assert "test_data" in outputs + + @pytest.mark.asyncio + async def test_multiple_variables(self): + """Test setting and using multiple variables.""" + yaml_def = { + "name": "multi_var_test", + "actions": [ + {"kind": "SetValue", "id": "set_a", "path": "turn.a", "value": "Hello"}, + {"kind": "SetValue", "id": "set_b", "path": "turn.b", "value": "World"}, + {"kind": "SendActivity", "id": "send", "activity": {"text": "=turn.a"}}, + ], + } + + builder = DeclarativeWorkflowBuilder(yaml_def) + workflow = builder.build() + + events = await workflow.run(ActionTrigger()) + outputs = events.get_outputs() + + assert "Hello" in outputs diff --git a/python/packages/declarative/tests/test_powerfx_functions.py b/python/packages/declarative/tests/test_powerfx_functions.py new file mode 100644 index 0000000000..050fa96786 --- /dev/null +++ b/python/packages/declarative/tests/test_powerfx_functions.py @@ -0,0 +1,242 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for custom PowerFx-like functions.""" + +from agent_framework_declarative._workflows._powerfx_functions import ( + CUSTOM_FUNCTIONS, + assistant_message, + concat_text, + count_rows, + find, + first, + is_blank, + last, + lower, + message_text, + search_table, + system_message, + upper, + user_message, +) + + +class TestMessageText: + """Tests for MessageText function.""" + + def test_message_text_from_string(self): + """Test extracting text from a plain string.""" + assert message_text("Hello") == "Hello" + + def test_message_text_from_single_dict(self): + """Test extracting text from a single message dict.""" + msg = {"role": "assistant", "content": "Hello world"} + assert message_text(msg) == "Hello world" + + def test_message_text_from_list(self): + """Test extracting text from a list of messages.""" + msgs = [ + {"role": "user", "content": "Hi"}, + {"role": "assistant", "content": "Hello"}, + ] + assert message_text(msgs) == "Hi Hello" + + def test_message_text_from_none(self): + """Test that None returns empty string.""" + assert message_text(None) == "" + + def test_message_text_empty_list(self): + """Test that empty list returns empty string.""" + assert message_text([]) == "" + + +class TestUserMessage: + """Tests for UserMessage function.""" + + def test_user_message_creates_dict(self): + """Test that UserMessage creates correct dict.""" + msg = user_message("Hello") + assert msg == {"role": "user", "content": "Hello"} + + def test_user_message_with_none(self): + """Test UserMessage with None.""" + msg = user_message(None) + assert msg == {"role": "user", "content": ""} + + +class TestAssistantMessage: + """Tests for AssistantMessage function.""" + + def test_assistant_message_creates_dict(self): + """Test that AssistantMessage creates correct dict.""" + msg = assistant_message("Hello") + assert msg == {"role": "assistant", "content": "Hello"} + + +class TestSystemMessage: + """Tests for SystemMessage function.""" + + def test_system_message_creates_dict(self): + """Test that SystemMessage creates correct dict.""" + msg = system_message("You are helpful") + assert msg == {"role": "system", "content": "You are helpful"} + + +class TestIsBlank: + """Tests for IsBlank function.""" + + def test_is_blank_none(self): + """Test that None is blank.""" + assert is_blank(None) is True + + def test_is_blank_empty_string(self): + """Test that empty string is blank.""" + assert is_blank("") is True + + def test_is_blank_whitespace(self): + """Test that whitespace-only string is blank.""" + assert is_blank(" ") is True + + def test_is_blank_empty_list(self): + """Test that empty list is blank.""" + assert is_blank([]) is True + + def test_is_blank_non_empty(self): + """Test that non-empty values are not blank.""" + assert is_blank("hello") is False + assert is_blank([1, 2, 3]) is False + assert is_blank(0) is False + + +class TestCountRows: + """Tests for CountRows function.""" + + def test_count_rows_list(self): + """Test counting list items.""" + assert count_rows([1, 2, 3]) == 3 + + def test_count_rows_empty(self): + """Test counting empty list.""" + assert count_rows([]) == 0 + + def test_count_rows_none(self): + """Test counting None.""" + assert count_rows(None) == 0 + + +class TestFirstLast: + """Tests for First and Last functions.""" + + def test_first_returns_first_item(self): + """Test that First returns first item.""" + assert first([1, 2, 3]) == 1 + + def test_last_returns_last_item(self): + """Test that Last returns last item.""" + assert last([1, 2, 3]) == 3 + + def test_first_empty_returns_none(self): + """Test that First returns None for empty list.""" + assert first([]) is None + + def test_last_empty_returns_none(self): + """Test that Last returns None for empty list.""" + assert last([]) is None + + +class TestFind: + """Tests for Find function.""" + + def test_find_substring(self): + """Test finding a substring.""" + result = find("world", "Hello world") + assert result == 7 # 1-based index + + def test_find_not_found(self): + """Test when substring not found - returns Blank (None) per PowerFx semantics.""" + result = find("xyz", "Hello world") + assert result is None + + def test_find_at_start(self): + """Test finding at start of string.""" + result = find("Hello", "Hello world") + assert result == 1 + + +class TestUpperLower: + """Tests for Upper and Lower functions.""" + + def test_upper(self): + """Test uppercase conversion.""" + assert upper("hello") == "HELLO" + + def test_lower(self): + """Test lowercase conversion.""" + assert lower("HELLO") == "hello" + + def test_upper_none(self): + """Test upper with None.""" + assert upper(None) == "" + + +class TestConcatText: + """Tests for Concat function.""" + + def test_concat_simple_list(self): + """Test concatenating simple list.""" + assert concat_text(["a", "b", "c"], separator=", ") == "a, b, c" + + def test_concat_with_field(self): + """Test concatenating with field extraction.""" + items = [{"name": "Alice"}, {"name": "Bob"}] + assert concat_text(items, field="name", separator=", ") == "Alice, Bob" + + +class TestSearchTable: + """Tests for Search function.""" + + def test_search_finds_matching(self): + """Test search finds matching items.""" + items = [ + {"name": "Alice", "age": 30}, + {"name": "Bob", "age": 25}, + {"name": "Charlie", "age": 35}, + ] + result = search_table(items, "Bob", "name") + assert len(result) == 1 + assert result[0]["name"] == "Bob" + + def test_search_case_insensitive(self): + """Test search is case insensitive.""" + items = [{"name": "Alice"}] + result = search_table(items, "alice", "name") + assert len(result) == 1 + + def test_search_partial_match(self): + """Test search finds partial matches.""" + items = [{"name": "Alice Smith"}, {"name": "Bob Jones"}] + result = search_table(items, "Smith", "name") + assert len(result) == 1 + + +class TestCustomFunctionsRegistry: + """Tests for the CUSTOM_FUNCTIONS registry.""" + + def test_all_functions_registered(self): + """Test that all functions are in the registry.""" + expected = [ + "MessageText", + "UserMessage", + "AssistantMessage", + "SystemMessage", + "IsBlank", + "CountRows", + "First", + "Last", + "Find", + "Upper", + "Lower", + "Concat", + "Search", + ] + for name in expected: + assert name in CUSTOM_FUNCTIONS diff --git a/python/packages/declarative/tests/test_workflow_factory.py b/python/packages/declarative/tests/test_workflow_factory.py new file mode 100644 index 0000000000..80b3342983 --- /dev/null +++ b/python/packages/declarative/tests/test_workflow_factory.py @@ -0,0 +1,279 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Unit tests for WorkflowFactory.""" + +import pytest + +from agent_framework_declarative._workflows._factory import ( + DeclarativeWorkflowError, + WorkflowFactory, +) + + +class TestWorkflowFactoryValidation: + """Tests for workflow definition validation.""" + + def test_missing_actions_raises(self): + """Test that missing 'actions' field raises an error.""" + factory = WorkflowFactory() + with pytest.raises(DeclarativeWorkflowError, match="must have 'actions' field"): + factory.create_workflow_from_yaml(""" +name: test-workflow +description: A test +# Missing 'actions' field +""") + + def test_actions_not_list_raises(self): + """Test that non-list 'actions' field raises an error.""" + factory = WorkflowFactory() + with pytest.raises(DeclarativeWorkflowError, match="'actions' must be a list"): + factory.create_workflow_from_yaml(""" +name: test-workflow +actions: "not a list" +""") + + def test_action_missing_kind_raises(self): + """Test that actions without 'kind' field raise an error.""" + factory = WorkflowFactory() + with pytest.raises(DeclarativeWorkflowError, match="missing 'kind' field"): + factory.create_workflow_from_yaml(""" +name: test-workflow +actions: + - path: turn.value + value: test +""") + + def test_valid_minimal_workflow(self): + """Test creating a valid minimal workflow.""" + factory = WorkflowFactory() + workflow = factory.create_workflow_from_yaml(""" +name: minimal-workflow +actions: + - kind: SetValue + path: turn.result + value: done +""") + + assert workflow is not None + assert workflow.name == "minimal-workflow" + + +class TestWorkflowFactoryExecution: + """Tests for workflow execution.""" + + @pytest.mark.asyncio + async def test_execute_set_value_workflow(self): + """Test executing a simple SetValue workflow.""" + factory = WorkflowFactory() + workflow = factory.create_workflow_from_yaml(""" +name: set-value-test +actions: + - kind: SetValue + path: turn.greeting + value: Hello + - kind: SendActivity + activity: + text: Done +""") + + result = await workflow.run({"input": "test"}) + outputs = result.get_outputs() + + # The workflow should produce output from SendActivity + assert len(outputs) > 0 + + @pytest.mark.asyncio + async def test_execute_send_activity_workflow(self): + """Test executing a workflow that sends activities.""" + factory = WorkflowFactory() + workflow = factory.create_workflow_from_yaml(""" +name: send-activity-test +actions: + - kind: SendActivity + activity: + text: Hello, world! +""") + + result = await workflow.run({"input": "test"}) + outputs = result.get_outputs() + + # Should have a TextOutputEvent + assert len(outputs) >= 1 + + @pytest.mark.asyncio + async def test_execute_foreach_workflow(self): + """Test executing a workflow with foreach.""" + factory = WorkflowFactory() + workflow = factory.create_workflow_from_yaml(""" +name: foreach-test +actions: + - kind: Foreach + source: + - apple + - banana + - cherry + itemName: fruit + actions: + - kind: AppendValue + path: turn.fruits + value: processed +""") + + _result = await workflow.run({}) # noqa: F841 + # The foreach should have processed 3 items + # We can check this by examining the workflow outputs + + @pytest.mark.asyncio + async def test_execute_if_workflow(self): + """Test executing a workflow with conditional branching.""" + factory = WorkflowFactory() + workflow = factory.create_workflow_from_yaml(""" +name: if-test +actions: + - kind: If + condition: true + then: + - kind: SendActivity + activity: + text: Condition was true + else: + - kind: SendActivity + activity: + text: Condition was false +""") + + result = await workflow.run({}) + outputs = result.get_outputs() + + # Check for the expected text in WorkflowOutputEvent + _text_outputs = [str(o) for o in outputs if isinstance(o, str) or hasattr(o, "data")] # noqa: F841 + assert any("Condition was true" in str(o) for o in outputs) + + +class TestWorkflowFactoryAgentRegistration: + """Tests for agent registration.""" + + def test_register_agent(self): + """Test registering an agent with the factory.""" + + class MockAgent: + name = "mock-agent" + + factory = WorkflowFactory() + factory.register_agent("myAgent", MockAgent()) + + assert "myAgent" in factory._agents + + def test_register_binding(self): + """Test registering a binding with the factory.""" + + def my_function(x): + return x * 2 + + factory = WorkflowFactory() + factory.register_binding("double", my_function) + + assert "double" in factory._bindings + assert factory._bindings["double"](5) == 10 + + +class TestWorkflowFactoryFromPath: + """Tests for loading workflows from file paths.""" + + def test_nonexistent_file_raises(self, tmp_path): + """Test that loading from a nonexistent file raises FileNotFoundError.""" + factory = WorkflowFactory() + with pytest.raises(FileNotFoundError): + factory.create_workflow_from_yaml_path(tmp_path / "nonexistent.yaml") + + def test_load_from_file(self, tmp_path): + """Test loading a workflow from a file.""" + workflow_file = tmp_path / "workflow.yaml" + workflow_file.write_text(""" +name: file-workflow +actions: + - kind: SetValue + path: turn.loaded + value: true +""") + + factory = WorkflowFactory() + workflow = factory.create_workflow_from_yaml_path(workflow_file) + + assert workflow is not None + assert workflow.name == "file-workflow" + + +class TestDisplayNameMetadata: + """Tests for displayName metadata support.""" + + @pytest.mark.asyncio + async def test_action_with_display_name(self): + """Test executing an action with displayName metadata.""" + factory = WorkflowFactory() + workflow = factory.create_workflow_from_yaml(""" +name: display-name-test +actions: + - kind: SetValue + id: set_greeting + displayName: Set the greeting message + path: turn.greeting + value: Hello + - kind: SendActivity + id: send_greeting + displayName: Send greeting to user + activity: + text: Hello, world! +""") + + result = await workflow.run({"input": "test"}) + outputs = result.get_outputs() + + # Should execute successfully with displayName metadata + assert len(outputs) >= 1 + + def test_action_context_display_name_property(self): + """Test that ActionContext provides displayName property.""" + from agent_framework_declarative._workflows._handlers import ActionContext + from agent_framework_declarative._workflows._state import WorkflowState + + state = WorkflowState() + ctx = ActionContext( + state=state, + action={ + "kind": "SetValue", + "id": "test_action", + "displayName": "Test Action Display Name", + "path": "turn.value", + "value": "test", + }, + execute_actions=lambda a, s: None, + agents={}, + bindings={}, + ) + + assert ctx.action_id == "test_action" + assert ctx.display_name == "Test Action Display Name" + assert ctx.action_kind == "SetValue" + + def test_action_context_without_display_name(self): + """Test ActionContext when displayName is not provided.""" + from agent_framework_declarative._workflows._handlers import ActionContext + from agent_framework_declarative._workflows._state import WorkflowState + + state = WorkflowState() + ctx = ActionContext( + state=state, + action={ + "kind": "SetValue", + "path": "turn.value", + "value": "test", + }, + execute_actions=lambda a, s: None, + agents={}, + bindings={}, + ) + + assert ctx.action_id is None + assert ctx.display_name is None + assert ctx.action_kind == "SetValue" diff --git a/python/packages/declarative/tests/test_workflow_handlers.py b/python/packages/declarative/tests/test_workflow_handlers.py new file mode 100644 index 0000000000..35055b3836 --- /dev/null +++ b/python/packages/declarative/tests/test_workflow_handlers.py @@ -0,0 +1,424 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Unit tests for action handlers.""" + +from collections.abc import AsyncGenerator +from typing import Any + +import pytest + +# Import handlers to register them +from agent_framework_declarative._workflows import ( + _actions_basic, # noqa: F401 + _actions_control_flow, # noqa: F401 + _actions_error, # noqa: F401 +) +from agent_framework_declarative._workflows._handlers import ( + ActionContext, + CustomEvent, + TextOutputEvent, + WorkflowEvent, + get_action_handler, + list_action_handlers, +) +from agent_framework_declarative._workflows._state import WorkflowState + + +def create_action_context( + action: dict[str, Any], + inputs: dict[str, Any] | None = None, + agents: dict[str, Any] | None = None, + bindings: dict[str, Any] | None = None, +) -> ActionContext: + """Helper to create an ActionContext for testing.""" + state = WorkflowState(inputs=inputs or {}) + + async def execute_actions( + actions: list[dict[str, Any]], state: WorkflowState + ) -> AsyncGenerator[WorkflowEvent, None]: + """Mock execute_actions that runs handlers for nested actions.""" + for nested_action in actions: + action_kind = nested_action.get("kind") + handler = get_action_handler(action_kind) + if handler: + ctx = ActionContext( + state=state, + action=nested_action, + execute_actions=execute_actions, + agents=agents or {}, + bindings=bindings or {}, + ) + async for event in handler(ctx): + yield event + + return ActionContext( + state=state, + action=action, + execute_actions=execute_actions, + agents=agents or {}, + bindings=bindings or {}, + ) + + +class TestActionHandlerRegistry: + """Tests for action handler registration.""" + + def test_basic_handlers_registered(self): + """Test that basic handlers are registered.""" + handlers = list_action_handlers() + assert "SetValue" in handlers + assert "AppendValue" in handlers + assert "SendActivity" in handlers + assert "EmitEvent" in handlers + + def test_control_flow_handlers_registered(self): + """Test that control flow handlers are registered.""" + handlers = list_action_handlers() + assert "Foreach" in handlers + assert "If" in handlers + assert "Switch" in handlers + assert "RepeatUntil" in handlers + assert "BreakLoop" in handlers + assert "ContinueLoop" in handlers + + def test_error_handlers_registered(self): + """Test that error handlers are registered.""" + handlers = list_action_handlers() + assert "ThrowException" in handlers + assert "TryCatch" in handlers + + def test_get_unknown_handler_returns_none(self): + """Test that getting an unknown handler returns None.""" + assert get_action_handler("UnknownAction") is None + + +class TestSetValueHandler: + """Tests for SetValue action handler.""" + + @pytest.mark.asyncio + async def test_set_simple_value(self): + """Test setting a simple value.""" + ctx = create_action_context({ + "kind": "SetValue", + "path": "turn.result", + "value": "test value", + }) + + handler = get_action_handler("SetValue") + events = [e async for e in handler(ctx)] + + assert len(events) == 0 # SetValue doesn't emit events + assert ctx.state.get("turn.result") == "test value" + + @pytest.mark.asyncio + async def test_set_value_from_input(self): + """Test setting a value from workflow inputs.""" + ctx = create_action_context( + { + "kind": "SetValue", + "path": "turn.copy", + "value": "literal", + }, + inputs={"original": "from input"}, + ) + + handler = get_action_handler("SetValue") + _events = [e async for e in handler(ctx)] # noqa: F841 + + assert ctx.state.get("turn.copy") == "literal" + + +class TestAppendValueHandler: + """Tests for AppendValue action handler.""" + + @pytest.mark.asyncio + async def test_append_to_new_list(self): + """Test appending to a non-existent list creates it.""" + ctx = create_action_context({ + "kind": "AppendValue", + "path": "turn.results", + "value": "item1", + }) + + handler = get_action_handler("AppendValue") + _events = [e async for e in handler(ctx)] # noqa: F841 + + assert ctx.state.get("turn.results") == ["item1"] + + @pytest.mark.asyncio + async def test_append_to_existing_list(self): + """Test appending to an existing list.""" + ctx = create_action_context({ + "kind": "AppendValue", + "path": "turn.results", + "value": "item2", + }) + ctx.state.set("turn.results", ["item1"]) + + handler = get_action_handler("AppendValue") + _events = [e async for e in handler(ctx)] # noqa: F841 + + assert ctx.state.get("turn.results") == ["item1", "item2"] + + +class TestSendActivityHandler: + """Tests for SendActivity action handler.""" + + @pytest.mark.asyncio + async def test_send_text_activity(self): + """Test sending a text activity.""" + ctx = create_action_context({ + "kind": "SendActivity", + "activity": { + "text": "Hello, world!", + }, + }) + + handler = get_action_handler("SendActivity") + events = [e async for e in handler(ctx)] + + assert len(events) == 1 + assert isinstance(events[0], TextOutputEvent) + assert events[0].text == "Hello, world!" + + +class TestEmitEventHandler: + """Tests for EmitEvent action handler.""" + + @pytest.mark.asyncio + async def test_emit_custom_event(self): + """Test emitting a custom event.""" + ctx = create_action_context({ + "kind": "EmitEvent", + "event": { + "name": "myEvent", + "data": {"key": "value"}, + }, + }) + + handler = get_action_handler("EmitEvent") + events = [e async for e in handler(ctx)] + + assert len(events) == 1 + assert isinstance(events[0], CustomEvent) + assert events[0].name == "myEvent" + assert events[0].data == {"key": "value"} + + +class TestForeachHandler: + """Tests for Foreach action handler.""" + + @pytest.mark.asyncio + async def test_foreach_basic_iteration(self): + """Test basic foreach iteration.""" + ctx = create_action_context({ + "kind": "Foreach", + "source": ["a", "b", "c"], + "itemName": "letter", + "actions": [ + { + "kind": "AppendValue", + "path": "turn.results", + "value": "processed", + } + ], + }) + + handler = get_action_handler("Foreach") + _events = [e async for e in handler(ctx)] # noqa: F841 + + assert ctx.state.get("turn.results") == ["processed", "processed", "processed"] + + @pytest.mark.asyncio + async def test_foreach_sets_item_and_index(self): + """Test that foreach sets item and index variables.""" + ctx = create_action_context({ + "kind": "Foreach", + "source": ["x", "y"], + "itemName": "item", + "indexName": "idx", + "actions": [], + }) + + # We'll check the last values after iteration + handler = get_action_handler("Foreach") + _events = [e async for e in handler(ctx)] # noqa: F841 + + # After iteration, the last item/index should be set + assert ctx.state.get("turn.item") == "y" + assert ctx.state.get("turn.idx") == 1 + + +class TestIfHandler: + """Tests for If action handler.""" + + @pytest.mark.asyncio + async def test_if_true_branch(self): + """Test that the 'then' branch executes when condition is true.""" + ctx = create_action_context({ + "kind": "If", + "condition": True, + "then": [ + {"kind": "SetValue", "path": "turn.branch", "value": "then"}, + ], + "else": [ + {"kind": "SetValue", "path": "turn.branch", "value": "else"}, + ], + }) + + handler = get_action_handler("If") + _events = [e async for e in handler(ctx)] # noqa: F841 + + assert ctx.state.get("turn.branch") == "then" + + @pytest.mark.asyncio + async def test_if_false_branch(self): + """Test that the 'else' branch executes when condition is false.""" + ctx = create_action_context({ + "kind": "If", + "condition": False, + "then": [ + {"kind": "SetValue", "path": "turn.branch", "value": "then"}, + ], + "else": [ + {"kind": "SetValue", "path": "turn.branch", "value": "else"}, + ], + }) + + handler = get_action_handler("If") + _events = [e async for e in handler(ctx)] # noqa: F841 + + assert ctx.state.get("turn.branch") == "else" + + +class TestSwitchHandler: + """Tests for Switch action handler.""" + + @pytest.mark.asyncio + async def test_switch_matching_case(self): + """Test switch with a matching case.""" + ctx = create_action_context({ + "kind": "Switch", + "value": "option2", + "cases": [ + { + "match": "option1", + "actions": [{"kind": "SetValue", "path": "turn.result", "value": "one"}], + }, + { + "match": "option2", + "actions": [{"kind": "SetValue", "path": "turn.result", "value": "two"}], + }, + ], + "default": [{"kind": "SetValue", "path": "turn.result", "value": "default"}], + }) + + handler = get_action_handler("Switch") + _events = [e async for e in handler(ctx)] # noqa: F841 + + assert ctx.state.get("turn.result") == "two" + + @pytest.mark.asyncio + async def test_switch_default_case(self): + """Test switch falls through to default.""" + ctx = create_action_context({ + "kind": "Switch", + "value": "unknown", + "cases": [ + { + "match": "option1", + "actions": [{"kind": "SetValue", "path": "turn.result", "value": "one"}], + }, + ], + "default": [{"kind": "SetValue", "path": "turn.result", "value": "default"}], + }) + + handler = get_action_handler("Switch") + _events = [e async for e in handler(ctx)] # noqa: F841 + + assert ctx.state.get("turn.result") == "default" + + +class TestRepeatUntilHandler: + """Tests for RepeatUntil action handler.""" + + @pytest.mark.asyncio + async def test_repeat_until_condition_met(self): + """Test repeat until condition becomes true.""" + ctx = create_action_context({ + "kind": "RepeatUntil", + "condition": False, # Will be evaluated each iteration + "maxIterations": 3, + "actions": [ + {"kind": "SetValue", "path": "turn.count", "value": 1}, + ], + }) + # Set up a counter that will cause the loop to exit + ctx.state.set("turn.count", 0) + + handler = get_action_handler("RepeatUntil") + _events = [e async for e in handler(ctx)] # noqa: F841 + + # With condition=False (literal), it will run maxIterations times + assert ctx.state.get("turn.iteration") == 3 + + +class TestTryCatchHandler: + """Tests for TryCatch action handler.""" + + @pytest.mark.asyncio + async def test_try_without_error(self): + """Test try block without errors.""" + ctx = create_action_context({ + "kind": "TryCatch", + "try": [ + {"kind": "SetValue", "path": "turn.result", "value": "success"}, + ], + "catch": [ + {"kind": "SetValue", "path": "turn.result", "value": "caught"}, + ], + }) + + handler = get_action_handler("TryCatch") + _events = [e async for e in handler(ctx)] # noqa: F841 + + assert ctx.state.get("turn.result") == "success" + + @pytest.mark.asyncio + async def test_try_with_throw_exception(self): + """Test catching a thrown exception.""" + ctx = create_action_context({ + "kind": "TryCatch", + "try": [ + {"kind": "ThrowException", "message": "Test error", "code": "ERR001"}, + ], + "catch": [ + {"kind": "SetValue", "path": "turn.result", "value": "caught"}, + ], + }) + + handler = get_action_handler("TryCatch") + _events = [e async for e in handler(ctx)] # noqa: F841 + + assert ctx.state.get("turn.result") == "caught" + assert ctx.state.get("turn.error.message") == "Test error" + assert ctx.state.get("turn.error.code") == "ERR001" + + @pytest.mark.asyncio + async def test_finally_always_executes(self): + """Test that finally block always executes.""" + ctx = create_action_context({ + "kind": "TryCatch", + "try": [ + {"kind": "SetValue", "path": "turn.try", "value": "ran"}, + ], + "finally": [ + {"kind": "SetValue", "path": "turn.finally", "value": "ran"}, + ], + }) + + handler = get_action_handler("TryCatch") + _events = [e async for e in handler(ctx)] # noqa: F841 + + assert ctx.state.get("turn.try") == "ran" + assert ctx.state.get("turn.finally") == "ran" diff --git a/python/packages/declarative/tests/test_workflow_samples_integration.py b/python/packages/declarative/tests/test_workflow_samples_integration.py new file mode 100644 index 0000000000..fc0ece9ac5 --- /dev/null +++ b/python/packages/declarative/tests/test_workflow_samples_integration.py @@ -0,0 +1,268 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Integration tests for workflow samples. + +These tests verify that the workflow samples from workflow-samples/ directory +can be parsed and validated by the WorkflowFactory. +""" + +from pathlib import Path + +import pytest +import yaml + +# Path to workflow samples - navigate from tests dir up to repo root +# tests/test_*.py -> packages/declarative/tests/ -> packages/declarative/ -> packages/ -> python/ -> repo root +WORKFLOW_SAMPLES_DIR = Path(__file__).parent.parent.parent.parent.parent / "workflow-samples" + + +def get_workflow_sample_files(): + """Get all .yaml files from the workflow-samples directory.""" + if not WORKFLOW_SAMPLES_DIR.exists(): + return [] + return list(WORKFLOW_SAMPLES_DIR.glob("*.yaml")) + + +class TestWorkflowSampleParsing: + """Tests that verify workflow samples can be parsed correctly.""" + + @pytest.fixture + def sample_files(self): + """Get list of sample files.""" + return get_workflow_sample_files() + + def test_samples_directory_exists(self): + """Verify the workflow-samples directory exists.""" + assert WORKFLOW_SAMPLES_DIR.exists(), f"Workflow samples directory not found at {WORKFLOW_SAMPLES_DIR}" + + def test_samples_exist(self, sample_files): + """Verify there are workflow sample files.""" + assert len(sample_files) > 0, "No workflow sample files found" + + @pytest.mark.parametrize("yaml_file", get_workflow_sample_files(), ids=lambda f: f.name) + def test_sample_yaml_is_valid(self, yaml_file): + """Test that each sample YAML file can be parsed.""" + with open(yaml_file) as f: + data = yaml.safe_load(f) + + assert data is not None, f"Failed to parse {yaml_file.name}" + assert "kind" in data, f"Missing 'kind' field in {yaml_file.name}" + assert data["kind"] == "Workflow", f"Expected kind: Workflow in {yaml_file.name}" + + @pytest.mark.parametrize("yaml_file", get_workflow_sample_files(), ids=lambda f: f.name) + def test_sample_has_trigger(self, yaml_file): + """Test that each sample has a trigger defined.""" + with open(yaml_file) as f: + data = yaml.safe_load(f) + + assert "trigger" in data, f"Missing 'trigger' field in {yaml_file.name}" + trigger = data["trigger"] + assert trigger is not None, f"Trigger is empty in {yaml_file.name}" + + @pytest.mark.parametrize("yaml_file", get_workflow_sample_files(), ids=lambda f: f.name) + def test_sample_has_actions(self, yaml_file): + """Test that each sample has actions defined.""" + with open(yaml_file) as f: + data = yaml.safe_load(f) + + trigger = data.get("trigger", {}) + actions = trigger.get("actions", []) + assert len(actions) > 0, f"No actions defined in {yaml_file.name}" + + @pytest.mark.parametrize("yaml_file", get_workflow_sample_files(), ids=lambda f: f.name) + def test_sample_actions_have_kind(self, yaml_file): + """Test that each action has a 'kind' field.""" + with open(yaml_file) as f: + data = yaml.safe_load(f) + + def check_actions(actions, path=""): + for i, action in enumerate(actions): + action_path = f"{path}[{i}]" + assert "kind" in action, f"Action missing 'kind' at {action_path} in {yaml_file.name}" + + # Check nested actions + for nested_key in ["actions", "elseActions", "thenActions"]: + if nested_key in action: + check_actions(action[nested_key], f"{action_path}.{nested_key}") + + # Check conditions + if "conditions" in action: + for j, cond in enumerate(action["conditions"]): + if "actions" in cond: + check_actions(cond["actions"], f"{action_path}.conditions[{j}].actions") + + # Check cases + if "cases" in action: + for j, case in enumerate(action["cases"]): + if "actions" in case: + check_actions(case["actions"], f"{action_path}.cases[{j}].actions") + + trigger = data.get("trigger", {}) + actions = trigger.get("actions", []) + check_actions(actions, "trigger.actions") + + +class TestWorkflowDefinitionParsing: + """Tests for parsing workflow definitions into structured objects.""" + + @pytest.mark.parametrize("yaml_file", get_workflow_sample_files(), ids=lambda f: f.name) + def test_extract_actions_from_sample(self, yaml_file): + """Test extracting all actions from a workflow sample.""" + with open(yaml_file) as f: + data = yaml.safe_load(f) + + # Collect all action kinds used + action_kinds: set[str] = set() + + def collect_actions(actions): + for action in actions: + action_kinds.add(action.get("kind", "Unknown")) + + # Collect from nested actions + for nested_key in ["actions", "elseActions", "thenActions"]: + if nested_key in action: + collect_actions(action[nested_key]) + + if "conditions" in action: + for cond in action["conditions"]: + if "actions" in cond: + collect_actions(cond["actions"]) + + if "cases" in action: + for case in action["cases"]: + if "actions" in case: + collect_actions(case["actions"]) + + trigger = data.get("trigger", {}) + actions = trigger.get("actions", []) + collect_actions(actions) + + # Verify we found some actions + assert len(action_kinds) > 0, f"No action kinds found in {yaml_file.name}" + + @pytest.mark.parametrize("yaml_file", get_workflow_sample_files(), ids=lambda f: f.name) + def test_extract_agent_names_from_sample(self, yaml_file): + """Test extracting agent names referenced in a workflow sample.""" + with open(yaml_file) as f: + data = yaml.safe_load(f) + + agent_names: set[str] = set() + + def collect_agents(actions): + for action in actions: + kind = action.get("kind", "") + + if kind in ("InvokeAzureAgent", "InvokePromptAgent"): + agent_config = action.get("agent", {}) + name = agent_config.get("name") if isinstance(agent_config, dict) else agent_config + if name and not str(name).startswith("="): + agent_names.add(name) + + # Collect from nested actions + for nested_key in ["actions", "elseActions", "thenActions"]: + if nested_key in action: + collect_agents(action[nested_key]) + + if "conditions" in action: + for cond in action["conditions"]: + if "actions" in cond: + collect_agents(cond["actions"]) + + if "cases" in action: + for case in action["cases"]: + if "actions" in case: + collect_agents(case["actions"]) + + trigger = data.get("trigger", {}) + actions = trigger.get("actions", []) + collect_agents(actions) + + # Log the agents found (some workflows may not use agents) + # Agent names: {agent_names} + + +class TestHandlerCoverage: + """Tests to verify handler coverage for workflow actions.""" + + @pytest.fixture + def all_action_kinds(self): + """Collect all action kinds used across all samples.""" + action_kinds: set[str] = set() + + def collect_actions(actions): + for action in actions: + action_kinds.add(action.get("kind", "Unknown")) + + for nested_key in ["actions", "elseActions", "thenActions"]: + if nested_key in action: + collect_actions(action[nested_key]) + + if "conditions" in action: + for cond in action["conditions"]: + if "actions" in cond: + collect_actions(cond["actions"]) + + if "cases" in action: + for case in action["cases"]: + if "actions" in case: + collect_actions(case["actions"]) + + for yaml_file in get_workflow_sample_files(): + with open(yaml_file) as f: + data = yaml.safe_load(f) + trigger = data.get("trigger", {}) + actions = trigger.get("actions", []) + collect_actions(actions) + + return action_kinds + + def test_handlers_exist_for_sample_actions(self, all_action_kinds): + """Test that handlers exist for all action kinds in samples.""" + from agent_framework_declarative._workflows._handlers import list_action_handlers + + registered_handlers = set(list_action_handlers()) + + # Handlers we expect but may not be in samples + expected_handlers = { + "SetValue", + "SetVariable", + "SetTextVariable", + "SetMultipleVariables", + "ResetVariable", + "ClearAllVariables", + "AppendValue", + "SendActivity", + "EmitEvent", + "Foreach", + "If", + "Switch", + "ConditionGroup", + "GotoAction", + "BreakLoop", + "ContinueLoop", + "RepeatUntil", + "TryCatch", + "ThrowException", + "EndWorkflow", + "EndConversation", + "InvokeAzureAgent", + "InvokePromptAgent", + "CreateConversation", + "AddConversationMessage", + "CopyConversationMessages", + "RetrieveConversationMessages", + "Question", + "RequestExternalInput", + "WaitForInput", + } + + # Check that sample action kinds have handlers + missing_handlers = all_action_kinds - registered_handlers - {"OnConversationStart"} # Trigger kind, not action + + if missing_handlers: + # Informational, not a failure, as some actions may be future work + pass + + # Check that we have handlers for the expected core set + core_handlers = registered_handlers & expected_handlers + assert len(core_handlers) > 10, "Expected more core handlers to be registered" diff --git a/python/packages/declarative/tests/test_workflow_state.py b/python/packages/declarative/tests/test_workflow_state.py new file mode 100644 index 0000000000..caf75f925d --- /dev/null +++ b/python/packages/declarative/tests/test_workflow_state.py @@ -0,0 +1,225 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Unit tests for WorkflowState class.""" + +import pytest + +from agent_framework_declarative._workflows._state import WorkflowState + + +class TestWorkflowStateInitialization: + """Tests for WorkflowState initialization.""" + + def test_empty_initialization(self): + """Test creating a WorkflowState with no inputs.""" + state = WorkflowState() + assert state.inputs == {} + assert state.outputs == {} + assert state.turn == {} + assert state.agent == {} + + def test_initialization_with_inputs(self): + """Test creating a WorkflowState with inputs.""" + state = WorkflowState(inputs={"query": "Hello", "count": 5}) + assert state.inputs == {"query": "Hello", "count": 5} + assert state.outputs == {} + + def test_inputs_are_immutable(self): + """Test that inputs cannot be modified through set().""" + state = WorkflowState(inputs={"query": "Hello"}) + with pytest.raises(ValueError, match="Cannot modify workflow.inputs"): + state.set("workflow.inputs.query", "Modified") + + +class TestWorkflowStateGetSet: + """Tests for get and set operations.""" + + def test_set_and_get_turn_variable(self): + """Test setting and getting a turn variable.""" + state = WorkflowState() + state.set("turn.counter", 10) + assert state.get("turn.counter") == 10 + + def test_set_and_get_nested_turn_variable(self): + """Test setting and getting a nested turn variable.""" + state = WorkflowState() + state.set("turn.data.nested.value", "test") + assert state.get("turn.data.nested.value") == "test" + + def test_set_and_get_workflow_output(self): + """Test setting and getting workflow output.""" + state = WorkflowState() + state.set("workflow.outputs.result", "success") + assert state.get("workflow.outputs.result") == "success" + assert state.outputs["result"] == "success" + + def test_get_with_default(self): + """Test get with default value.""" + state = WorkflowState() + assert state.get("turn.nonexistent") is None + assert state.get("turn.nonexistent", "default") == "default" + + def test_get_workflow_inputs(self): + """Test getting workflow inputs.""" + state = WorkflowState(inputs={"query": "test"}) + assert state.get("workflow.inputs.query") == "test" + + def test_set_custom_namespace(self): + """Test setting a custom namespace variable.""" + state = WorkflowState() + state.set("custom.myvar", "value") + assert state.get("custom.myvar") == "value" + + +class TestWorkflowStateAppend: + """Tests for append operation.""" + + def test_append_to_nonexistent_list(self): + """Test appending to a path that doesn't exist yet.""" + state = WorkflowState() + state.append("turn.results", "item1") + assert state.get("turn.results") == ["item1"] + + def test_append_to_existing_list(self): + """Test appending to an existing list.""" + state = WorkflowState() + state.set("turn.results", ["item1"]) + state.append("turn.results", "item2") + assert state.get("turn.results") == ["item1", "item2"] + + def test_append_to_non_list_raises(self): + """Test that appending to a non-list raises ValueError.""" + state = WorkflowState() + state.set("turn.value", "not a list") + with pytest.raises(ValueError, match="Cannot append to non-list"): + state.append("turn.value", "item") + + +class TestWorkflowStateAgentResult: + """Tests for agent result management.""" + + def test_set_agent_result(self): + """Test setting agent result.""" + state = WorkflowState() + state.set_agent_result( + text="Agent response", + messages=[{"role": "assistant", "content": "Hello"}], + tool_calls=[{"name": "tool1"}], + ) + assert state.agent["text"] == "Agent response" + assert len(state.agent["messages"]) == 1 + assert len(state.agent["toolCalls"]) == 1 + + def test_get_agent_result_via_path(self): + """Test getting agent result via path.""" + state = WorkflowState() + state.set_agent_result(text="Response") + assert state.get("agent.text") == "Response" + + def test_reset_agent(self): + """Test resetting agent result.""" + state = WorkflowState() + state.set_agent_result(text="Response") + state.reset_agent() + assert state.agent == {} + + +class TestWorkflowStateConversation: + """Tests for conversation management.""" + + def test_add_conversation_message(self): + """Test adding a conversation message.""" + state = WorkflowState() + message = {"role": "user", "content": "Hello"} + state.add_conversation_message(message) + assert len(state.conversation["messages"]) == 1 + assert state.conversation["messages"][0] == message + + def test_get_conversation_history(self): + """Test getting conversation history.""" + state = WorkflowState() + state.add_conversation_message({"role": "user", "content": "Hi"}) + state.add_conversation_message({"role": "assistant", "content": "Hello"}) + assert len(state.get("conversation.history")) == 2 + + +class TestWorkflowStatePowerFx: + """Tests for PowerFx expression evaluation.""" + + def test_eval_non_expression(self): + """Test that non-expressions are returned as-is.""" + state = WorkflowState() + assert state.eval("plain text") == "plain text" + + def test_eval_if_expression_with_literal(self): + """Test eval_if_expression with a literal value.""" + state = WorkflowState() + assert state.eval_if_expression(42) == 42 + assert state.eval_if_expression(["a", "b"]) == ["a", "b"] + + def test_eval_if_expression_with_non_expression_string(self): + """Test eval_if_expression with a non-expression string.""" + state = WorkflowState() + assert state.eval_if_expression("plain text") == "plain text" + + def test_to_powerfx_symbols(self): + """Test converting state to PowerFx symbols.""" + state = WorkflowState(inputs={"query": "test"}) + state.set("turn.counter", 5) + state.set("workflow.outputs.result", "done") + + symbols = state.to_powerfx_symbols() + assert symbols["workflow"]["inputs"]["query"] == "test" + assert symbols["workflow"]["outputs"]["result"] == "done" + assert symbols["turn"]["counter"] == 5 + + +class TestWorkflowStateClone: + """Tests for state cloning.""" + + def test_clone_creates_copy(self): + """Test that clone creates a copy of the state.""" + state = WorkflowState(inputs={"query": "test"}) + state.set("turn.counter", 5) + + cloned = state.clone() + assert cloned.get("workflow.inputs.query") == "test" + assert cloned.get("turn.counter") == 5 + + def test_clone_is_independent(self): + """Test that modifications to clone don't affect original.""" + state = WorkflowState() + state.set("turn.value", "original") + + cloned = state.clone() + cloned.set("turn.value", "modified") + + assert state.get("turn.value") == "original" + assert cloned.get("turn.value") == "modified" + + +class TestWorkflowStateResetTurn: + """Tests for turn reset.""" + + def test_reset_turn_clears_turn_variables(self): + """Test that reset_turn clears turn variables.""" + state = WorkflowState() + state.set("turn.var1", "value1") + state.set("turn.var2", "value2") + + state.reset_turn() + + assert state.get("turn.var1") is None + assert state.get("turn.var2") is None + assert state.turn == {} + + def test_reset_turn_preserves_other_state(self): + """Test that reset_turn preserves other state.""" + state = WorkflowState(inputs={"query": "test"}) + state.set("workflow.outputs.result", "done") + state.set("turn.temp", "will be cleared") + + state.reset_turn() + + assert state.get("workflow.inputs.query") == "test" + assert state.get("workflow.outputs.result") == "done" diff --git a/python/packages/devui/agent_framework_devui/_executor.py b/python/packages/devui/agent_framework_devui/_executor.py index 813bc4d4cc..0e7d863646 100644 --- a/python/packages/devui/agent_framework_devui/_executor.py +++ b/python/packages/devui/agent_framework_devui/_executor.py @@ -507,7 +507,9 @@ async def _execute_workflow( # First run - pass DevUI's checkpoint storage to enable checkpointing logger.info(f"Starting fresh workflow in session {conversation_id}") + logger.info(f"Raw request.input: {request.input!r} (type: {type(request.input).__name__})") parsed_input = await self._parse_workflow_input(workflow, request.input) + logger.info(f"Parsed workflow input: {parsed_input!r} (type: {type(parsed_input).__name__})") async for event in workflow.run_stream(parsed_input, checkpoint_storage=checkpoint_storage): if isinstance(event, RequestInfoEvent): diff --git a/python/packages/devui/agent_framework_devui/_mapper.py b/python/packages/devui/agent_framework_devui/_mapper.py index 5adff1cd2f..c6984b8a0d 100644 --- a/python/packages/devui/agent_framework_devui/_mapper.py +++ b/python/packages/devui/agent_framework_devui/_mapper.py @@ -924,6 +924,11 @@ async def _convert_workflow_event(self, event: Any, context: dict[str, Any]) -> if context.get("current_executor_id") == executor_id: context.pop("current_executor_id", None) + # Serialize the result data to ensure JSON compatibility + # This handles declarative workflow types like ActionComplete + raw_result = getattr(event, "data", None) + serialized_result = self._serialize_value(raw_result) if raw_result is not None else None + # Create ExecutorActionItem with completed status # ExecutorCompletedEvent uses 'data' field, not 'result' # Serialize the result data to ensure it's JSON-serializable diff --git a/python/packages/devui/agent_framework_devui/_server.py b/python/packages/devui/agent_framework_devui/_server.py index 26630945cb..cafa202a59 100644 --- a/python/packages/devui/agent_framework_devui/_server.py +++ b/python/packages/devui/agent_framework_devui/_server.py @@ -480,34 +480,53 @@ async def get_entity_info(entity_id: str) -> EntityInfo: workflow_dump = {k: v for k, v in entity_obj.__dict__.items() if not k.startswith("_")} # Get input schema information - input_schema = {} + # First, check if the workflow has an explicit input_schema (e.g., declarative workflows) + input_schema: dict[str, Any] = {} input_type_name = "Unknown" start_executor_id = "" - try: - from ._utils import ( - extract_executor_message_types, - generate_input_schema, - select_primary_input_type, - ) + # Check for explicit input_schema on workflow (declarative workflows set this) + if hasattr(entity_obj, "input_schema") and entity_obj.input_schema: + input_schema = entity_obj.input_schema + input_type_name = "Inputs" - start_executor = entity_obj.get_start_executor() - except Exception as e: - logger.debug(f"Could not extract input info for workflow {entity_id}: {e}") - else: - if start_executor: - start_executor_id = getattr(start_executor, "executor_id", "") or getattr( - start_executor, "id", "" + # Fall back to introspecting the start executor's message types + if not input_schema: + try: + from ._utils import ( + extract_executor_message_types, + generate_input_schema, + select_primary_input_type, ) - message_types = extract_executor_message_types(start_executor) - input_type = select_primary_input_type(message_types) + start_executor = entity_obj.get_start_executor() + except Exception as e: + logger.debug(f"Could not extract input info for workflow {entity_id}: {e}") + else: + if start_executor: + start_executor_id = getattr(start_executor, "executor_id", "") or getattr( + start_executor, "id", "" + ) + + message_types = extract_executor_message_types(start_executor) + input_type = select_primary_input_type(message_types) + + if input_type: + input_type_name = getattr(input_type, "__name__", str(input_type)) - if input_type: - input_type_name = getattr(input_type, "__name__", str(input_type)) + # Generate schema using comprehensive schema generation + input_schema = generate_input_schema(input_type) - # Generate schema using comprehensive schema generation - input_schema = generate_input_schema(input_type) + # Get start executor ID if not already set + if not start_executor_id: + try: + start_executor = entity_obj.get_start_executor() + if start_executor: + start_executor_id = getattr(start_executor, "executor_id", "") or getattr( + start_executor, "id", "" + ) + except Exception: + logger.debug(f"Could not get start executor for workflow {entity_id}") if not input_schema: input_schema = {"type": "string"} diff --git a/python/packages/devui/agent_framework_devui/_utils.py b/python/packages/devui/agent_framework_devui/_utils.py index 3c17c072f7..fdf39dc0a6 100644 --- a/python/packages/devui/agent_framework_devui/_utils.py +++ b/python/packages/devui/agent_framework_devui/_utils.py @@ -439,6 +439,34 @@ def generate_input_schema(input_type: type) -> dict[str, Any]: # ============================================================================ +def _safe_isinstance(obj: Any, target_type: type) -> bool: + """Safely check isinstance, handling parameterized generics. + + In Python 3.9+, isinstance() cannot be called with parameterized generics + like list[str] or Mapping[str, Any]. This helper extracts the origin type + for such cases. + + Args: + obj: Object to check + target_type: Type to check against (may be parameterized generic) + + Returns: + True if obj is an instance of target_type (or its origin for generics) + """ + # Get the origin type for parameterized generics (e.g., list for list[str]) + origin = get_origin(target_type) + if origin is not None: + # Use the origin type for isinstance check + return isinstance(obj, origin) + + # For regular types, use isinstance directly + try: + return isinstance(obj, target_type) + except TypeError: + # Fallback: if isinstance fails, return False + return False + + def parse_input_for_type(input_data: Any, target_type: type) -> Any: """Parse input data to match the target type. @@ -455,8 +483,8 @@ def parse_input_for_type(input_data: Any, target_type: type) -> Any: Returns: Parsed input matching target_type, or original input if parsing fails """ - # If already correct type, return as-is - if isinstance(input_data, target_type): + # If already correct type, return as-is (use safe isinstance for generics) + if _safe_isinstance(input_data, target_type): return input_data # Handle string input diff --git a/python/packages/devui/agent_framework_devui/ui/assets/index.js b/python/packages/devui/agent_framework_devui/ui/assets/index.js index d12b71a838..505ba27ccf 100644 --- a/python/packages/devui/agent_framework_devui/ui/assets/index.js +++ b/python/packages/devui/agent_framework_devui/ui/assets/index.js @@ -1,4 +1,4 @@ -function yE(e,n){for(var r=0;ra[l]})}}}return Object.freeze(Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}))}(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))a(l);new MutationObserver(l=>{for(const c of l)if(c.type==="childList")for(const d of c.addedNodes)d.tagName==="LINK"&&d.rel==="modulepreload"&&a(d)}).observe(document,{childList:!0,subtree:!0});function r(l){const c={};return l.integrity&&(c.integrity=l.integrity),l.referrerPolicy&&(c.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?c.credentials="include":l.crossOrigin==="anonymous"?c.credentials="omit":c.credentials="same-origin",c}function a(l){if(l.ep)return;l.ep=!0;const c=r(l);fetch(l.href,c)}})();function yp(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var Gm={exports:{}},Bi={};/** +function yE(e,n){for(var r=0;ra[l]})}}}return Object.freeze(Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}))}(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))a(l);new MutationObserver(l=>{for(const c of l)if(c.type==="childList")for(const d of c.addedNodes)d.tagName==="LINK"&&d.rel==="modulepreload"&&a(d)}).observe(document,{childList:!0,subtree:!0});function r(l){const c={};return l.integrity&&(c.integrity=l.integrity),l.referrerPolicy&&(c.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?c.credentials="include":l.crossOrigin==="anonymous"?c.credentials="omit":c.credentials="same-origin",c}function a(l){if(l.ep)return;l.ep=!0;const c=r(l);fetch(l.href,c)}})();function yp(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var Gm={exports:{}},Pi={};/** * @license React * react-jsx-runtime.production.js * @@ -6,7 +6,7 @@ function yE(e,n){for(var r=0;r>>1,C=k[H];if(0>>1;H<$;){var Y=2*(H+1)-1,V=k[Y],W=Y+1,fe=k[W];if(0>l(V,I))Wl(fe,V)?(k[H]=fe,k[W]=I,H=W):(k[H]=V,k[Y]=I,H=Y);else if(Wl(fe,I))k[H]=fe,k[W]=I,H=W;else break e}}return L}function l(k,L){var I=k.sortIndex-L.sortIndex;return I!==0?I:k.id-L.id}if(e.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var c=performance;e.unstable_now=function(){return c.now()}}else{var d=Date,f=d.now();e.unstable_now=function(){return d.now()-f}}var m=[],h=[],g=1,y=null,x=3,b=!1,S=!1,N=!1,j=!1,_=typeof setTimeout=="function"?setTimeout:null,M=typeof clearTimeout=="function"?clearTimeout:null,E=typeof setImmediate<"u"?setImmediate:null;function T(k){for(var L=r(h);L!==null;){if(L.callback===null)a(h);else if(L.startTime<=k)a(h),L.sortIndex=L.expirationTime,n(m,L);else break;L=r(h)}}function R(k){if(N=!1,T(k),!S)if(r(m)!==null)S=!0,D||(D=!0,G());else{var L=r(h);L!==null&&U(R,L.startTime-k)}}var D=!1,O=-1,B=5,q=-1;function K(){return j?!0:!(e.unstable_now()-qk&&K());){var H=y.callback;if(typeof H=="function"){y.callback=null,x=y.priorityLevel;var C=H(y.expirationTime<=k);if(k=e.unstable_now(),typeof C=="function"){y.callback=C,T(k),L=!0;break t}y===r(m)&&a(m),T(k)}else a(m);y=r(m)}if(y!==null)L=!0;else{var $=r(h);$!==null&&U(R,$.startTime-k),L=!1}}break e}finally{y=null,x=I,b=!1}L=void 0}}finally{L?G():D=!1}}}var G;if(typeof E=="function")G=function(){E(J)};else if(typeof MessageChannel<"u"){var Z=new MessageChannel,P=Z.port2;Z.port1.onmessage=J,G=function(){P.postMessage(null)}}else G=function(){_(J,0)};function U(k,L){O=_(function(){k(e.unstable_now())},L)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(k){k.callback=null},e.unstable_forceFrameRate=function(k){0>k||125H?(k.sortIndex=I,n(h,k),r(m)===null&&k===r(h)&&(N?(M(O),O=-1):N=!0,U(R,I-H))):(k.sortIndex=C,n(m,k),S||b||(S=!0,D||(D=!0,G()))),k},e.unstable_shouldYield=K,e.unstable_wrapCallback=function(k){var L=x;return function(){var I=x;x=L;try{return k.apply(this,arguments)}finally{x=I}}}})(Km)),Km}var tv;function SE(){return tv||(tv=1,Wm.exports=NE()),Wm.exports}var Qm={exports:{}},Wt={};/** + */var ev;function NE(){return ev||(ev=1,(function(e){function n(k,L){var I=k.length;k.push(L);e:for(;0>>1,C=k[H];if(0>>1;H<$;){var Y=2*(H+1)-1,V=k[Y],K=Y+1,fe=k[K];if(0>l(V,I))Kl(fe,V)?(k[H]=fe,k[K]=I,H=K):(k[H]=V,k[Y]=I,H=Y);else if(Kl(fe,I))k[H]=fe,k[K]=I,H=K;else break e}}return L}function l(k,L){var I=k.sortIndex-L.sortIndex;return I!==0?I:k.id-L.id}if(e.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var c=performance;e.unstable_now=function(){return c.now()}}else{var d=Date,f=d.now();e.unstable_now=function(){return d.now()-f}}var m=[],h=[],g=1,y=null,x=3,b=!1,S=!1,N=!1,j=!1,_=typeof setTimeout=="function"?setTimeout:null,M=typeof clearTimeout=="function"?clearTimeout:null,E=typeof setImmediate<"u"?setImmediate:null;function T(k){for(var L=r(h);L!==null;){if(L.callback===null)a(h);else if(L.startTime<=k)a(h),L.sortIndex=L.expirationTime,n(m,L);else break;L=r(h)}}function R(k){if(N=!1,T(k),!S)if(r(m)!==null)S=!0,D||(D=!0,G());else{var L=r(h);L!==null&&U(R,L.startTime-k)}}var D=!1,O=-1,P=5,q=-1;function Q(){return j?!0:!(e.unstable_now()-qk&&Q());){var H=y.callback;if(typeof H=="function"){y.callback=null,x=y.priorityLevel;var C=H(y.expirationTime<=k);if(k=e.unstable_now(),typeof C=="function"){y.callback=C,T(k),L=!0;break t}y===r(m)&&a(m),T(k)}else a(m);y=r(m)}if(y!==null)L=!0;else{var $=r(h);$!==null&&U(R,$.startTime-k),L=!1}}break e}finally{y=null,x=I,b=!1}L=void 0}}finally{L?G():D=!1}}}var G;if(typeof E=="function")G=function(){E(ee)};else if(typeof MessageChannel<"u"){var W=new MessageChannel,B=W.port2;W.port1.onmessage=ee,G=function(){B.postMessage(null)}}else G=function(){_(ee,0)};function U(k,L){O=_(function(){k(e.unstable_now())},L)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(k){k.callback=null},e.unstable_forceFrameRate=function(k){0>k||125H?(k.sortIndex=I,n(h,k),r(m)===null&&k===r(h)&&(N?(M(O),O=-1):N=!0,U(R,I-H))):(k.sortIndex=C,n(m,k),S||b||(S=!0,D||(D=!0,G()))),k},e.unstable_shouldYield=Q,e.unstable_wrapCallback=function(k){var L=x;return function(){var I=x;x=L;try{return k.apply(this,arguments)}finally{x=I}}}})(Km)),Km}var tv;function SE(){return tv||(tv=1,Wm.exports=NE()),Wm.exports}var Qm={exports:{}},Wt={};/** * @license React * react-dom.production.js * @@ -38,15 +38,15 @@ function yE(e,n){for(var r=0;rC||(t.current=H[C],H[C]=null,C--)}function V(t,s){C++,H[C]=t.current,t.current=s}var W=$(null),fe=$(null),ue=$(null),te=$(null);function ie(t,s){switch(V(ue,s),V(fe,t),V(W,null),s.nodeType){case 9:case 11:t=(t=s.documentElement)&&(t=t.namespaceURI)?jy(t):0;break;default:if(t=s.tagName,s=s.namespaceURI)s=jy(s),t=_y(s,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}Y(W),V(W,t)}function ge(){Y(W),Y(fe),Y(ue)}function be(t){t.memoizedState!==null&&V(te,t);var s=W.current,i=_y(s,t.type);s!==i&&(V(fe,t),V(W,i))}function we(t){fe.current===t&&(Y(W),Y(fe)),te.current===t&&(Y(te),zi._currentValue=I)}var ne=Object.prototype.hasOwnProperty,pe=e.unstable_scheduleCallback,he=e.unstable_cancelCallback,ee=e.unstable_shouldYield,ve=e.unstable_requestPaint,ye=e.unstable_now,Te=e.unstable_getCurrentPriorityLevel,je=e.unstable_ImmediatePriority,$e=e.unstable_UserBlockingPriority,it=e.unstable_NormalPriority,ze=e.unstable_LowPriority,Se=e.unstable_IdlePriority,Pe=e.log,Ee=e.unstable_setDisableYieldValue,He=null,Fe=null;function Nt(t){if(typeof Pe=="function"&&Ee(t),Fe&&typeof Fe.setStrictMode=="function")try{Fe.setStrictMode(He,t)}catch{}}var yt=Math.clz32?Math.clz32:xe,hs=Math.log,wo=Math.LN2;function xe(t){return t>>>=0,t===0?32:31-(hs(t)/wo|0)|0}var Re=256,Ue=4194304;function Et(t){var s=t&42;if(s!==0)return s;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t&4194048;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function Dn(t,s,i){var u=t.pendingLanes;if(u===0)return 0;var p=0,v=t.suspendedLanes,A=t.pingedLanes;t=t.warmLanes;var z=u&134217727;return z!==0?(u=z&~v,u!==0?p=Et(u):(A&=z,A!==0?p=Et(A):i||(i=z&~t,i!==0&&(p=Et(i))))):(z=u&~v,z!==0?p=Et(z):A!==0?p=Et(A):i||(i=u&~t,i!==0&&(p=Et(i)))),p===0?0:s!==0&&s!==p&&(s&v)===0&&(v=p&-p,i=s&-s,v>=i||v===32&&(i&4194048)!==0)?s:p}function Le(t,s){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&s)===0}function Ne(t,s){switch(t){case 1:case 2:case 4:case 8:case 64:return s+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return s+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function lt(){var t=Re;return Re<<=1,(Re&4194048)===0&&(Re=256),t}function ot(){var t=Ue;return Ue<<=1,(Ue&62914560)===0&&(Ue=4194304),t}function At(t){for(var s=[],i=0;31>i;i++)s.push(t);return s}function en(t,s){t.pendingLanes|=s,s!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function On(t,s,i,u,p,v){var A=t.pendingLanes;t.pendingLanes=i,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=i,t.entangledLanes&=i,t.errorRecoveryDisabledLanes&=i,t.shellSuspendCounter=0;var z=t.entanglements,F=t.expirationTimes,re=t.hiddenUpdates;for(i=A&~i;0C||(t.current=H[C],H[C]=null,C--)}function V(t,s){C++,H[C]=t.current,t.current=s}var K=$(null),fe=$(null),ue=$(null),te=$(null);function ie(t,s){switch(V(ue,s),V(fe,t),V(K,null),s.nodeType){case 9:case 11:t=(t=s.documentElement)&&(t=t.namespaceURI)?jy(t):0;break;default:if(t=s.tagName,s=s.namespaceURI)s=jy(s),t=_y(s,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}Y(K),V(K,t)}function xe(){Y(K),Y(fe),Y(ue)}function ve(t){t.memoizedState!==null&&V(te,t);var s=K.current,i=_y(s,t.type);s!==i&&(V(fe,t),V(K,i))}function be(t){fe.current===t&&(Y(K),Y(fe)),te.current===t&&(Y(te),zi._currentValue=I)}var ne=Object.prototype.hasOwnProperty,he=e.unstable_scheduleCallback,X=e.unstable_cancelCallback,pe=e.unstable_shouldYield,Ne=e.unstable_requestPaint,ye=e.unstable_now,Oe=e.unstable_getCurrentPriorityLevel,Se=e.unstable_ImmediatePriority,Ie=e.unstable_UserBlockingPriority,Xe=e.unstable_NormalPriority,He=e.unstable_LowPriority,Re=e.unstable_IdlePriority,Ve=e.log,_e=e.unstable_setDisableYieldValue,$e=null,Fe=null;function Nt(t){if(typeof Ve=="function"&&_e(t),Fe&&typeof Fe.setStrictMode=="function")try{Fe.setStrictMode($e,t)}catch{}}var yt=Math.clz32?Math.clz32:ge,hs=Math.log,wo=Math.LN2;function ge(t){return t>>>=0,t===0?32:31-(hs(t)/wo|0)|0}var Me=256,Be=4194304;function Et(t){var s=t&42;if(s!==0)return s;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t&4194048;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function Rn(t,s,i){var u=t.pendingLanes;if(u===0)return 0;var p=0,v=t.suspendedLanes,A=t.pingedLanes;t=t.warmLanes;var z=u&134217727;return z!==0?(u=z&~v,u!==0?p=Et(u):(A&=z,A!==0?p=Et(A):i||(i=z&~t,i!==0&&(p=Et(i))))):(z=u&~v,z!==0?p=Et(z):A!==0?p=Et(A):i||(i=u&~t,i!==0&&(p=Et(i)))),p===0?0:s!==0&&s!==p&&(s&v)===0&&(v=p&-p,i=s&-s,v>=i||v===32&&(i&4194048)!==0)?s:p}function Le(t,s){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&s)===0}function we(t,s){switch(t){case 1:case 2:case 4:case 8:case 64:return s+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return s+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function lt(){var t=Me;return Me<<=1,(Me&4194048)===0&&(Me=256),t}function at(){var t=Be;return Be<<=1,(Be&62914560)===0&&(Be=4194304),t}function At(t){for(var s=[],i=0;31>i;i++)s.push(t);return s}function en(t,s){t.pendingLanes|=s,s!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function Dn(t,s,i,u,p,v){var A=t.pendingLanes;t.pendingLanes=i,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=i,t.entangledLanes&=i,t.errorRecoveryDisabledLanes&=i,t.shellSuspendCounter=0;var z=t.entanglements,F=t.expirationTimes,re=t.hiddenUpdates;for(i=A&~i;0)":-1p||F[u]!==re[p]){var le=` `+F[u].replace(" at new "," at ");return t.displayName&&le.includes("")&&(le=le.replace("",t.displayName)),le}while(1<=u&&0<=p);break}}}finally{qa=!1,Error.prepareStackTrace=i}return(i=t?t.displayName||t.name:"")?ys(i):""}function Ud(t){switch(t.tag){case 26:case 27:case 5:return ys(t.type);case 16:return ys("Lazy");case 13:return ys("Suspense");case 19:return ys("SuspenseList");case 0:case 15:return Fa(t.type,!1);case 11:return Fa(t.type.render,!1);case 1:return Fa(t.type,!0);case 31:return ys("Activity");default:return""}}function Il(t){try{var s="";do s+=Ud(t),t=t.return;while(t);return s}catch(i){return` Error generating stack: `+i.message+` -`+i.stack}}function tn(t){switch(typeof t){case"bigint":case"boolean":case"number":case"string":case"undefined":return t;case"object":return t;default:return""}}function Ll(t){var s=t.type;return(t=t.nodeName)&&t.toLowerCase()==="input"&&(s==="checkbox"||s==="radio")}function Vd(t){var s=Ll(t)?"checked":"value",i=Object.getOwnPropertyDescriptor(t.constructor.prototype,s),u=""+t[s];if(!t.hasOwnProperty(s)&&typeof i<"u"&&typeof i.get=="function"&&typeof i.set=="function"){var p=i.get,v=i.set;return Object.defineProperty(t,s,{configurable:!0,get:function(){return p.call(this)},set:function(A){u=""+A,v.call(this,A)}}),Object.defineProperty(t,s,{enumerable:i.enumerable}),{getValue:function(){return u},setValue:function(A){u=""+A},stopTracking:function(){t._valueTracker=null,delete t[s]}}}}function jo(t){t._valueTracker||(t._valueTracker=Vd(t))}function Ya(t){if(!t)return!1;var s=t._valueTracker;if(!s)return!0;var i=s.getValue(),u="";return t&&(u=Ll(t)?t.checked?"true":"false":t.value),t=u,t!==i?(s.setValue(t),!0):!1}function _o(t){if(t=t||(typeof document<"u"?document:void 0),typeof t>"u")return null;try{return t.activeElement||t.body}catch{return t.body}}var qd=/[\n"\\]/g;function nn(t){return t.replace(qd,function(s){return"\\"+s.charCodeAt(0).toString(16)+" "})}function zr(t,s,i,u,p,v,A,z){t.name="",A!=null&&typeof A!="function"&&typeof A!="symbol"&&typeof A!="boolean"?t.type=A:t.removeAttribute("type"),s!=null?A==="number"?(s===0&&t.value===""||t.value!=s)&&(t.value=""+tn(s)):t.value!==""+tn(s)&&(t.value=""+tn(s)):A!=="submit"&&A!=="reset"||t.removeAttribute("value"),s!=null?Ga(t,A,tn(s)):i!=null?Ga(t,A,tn(i)):u!=null&&t.removeAttribute("value"),p==null&&v!=null&&(t.defaultChecked=!!v),p!=null&&(t.checked=p&&typeof p!="function"&&typeof p!="symbol"),z!=null&&typeof z!="function"&&typeof z!="symbol"&&typeof z!="boolean"?t.name=""+tn(z):t.removeAttribute("name")}function Hl(t,s,i,u,p,v,A,z){if(v!=null&&typeof v!="function"&&typeof v!="symbol"&&typeof v!="boolean"&&(t.type=v),s!=null||i!=null){if(!(v!=="submit"&&v!=="reset"||s!=null))return;i=i!=null?""+tn(i):"",s=s!=null?""+tn(s):i,z||s===t.value||(t.value=s),t.defaultValue=s}u=u??p,u=typeof u!="function"&&typeof u!="symbol"&&!!u,t.checked=z?t.checked:!!u,t.defaultChecked=!!u,A!=null&&typeof A!="function"&&typeof A!="symbol"&&typeof A!="boolean"&&(t.name=A)}function Ga(t,s,i){s==="number"&&_o(t.ownerDocument)===t||t.defaultValue===""+i||(t.defaultValue=""+i)}function vs(t,s,i,u){if(t=t.options,s){s={};for(var p=0;p"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Zd=!1;if(bs)try{var Za={};Object.defineProperty(Za,"passive",{get:function(){Zd=!0}}),window.addEventListener("test",Za,Za),window.removeEventListener("test",Za,Za)}catch{Zd=!1}var Zs=null,Wd=null,Bl=null;function kg(){if(Bl)return Bl;var t,s=Wd,i=s.length,u,p="value"in Zs?Zs.value:Zs.textContent,v=p.length;for(t=0;t=Qa),Og=" ",zg=!1;function Ig(t,s){switch(t){case"keyup":return Uj.indexOf(s.keyCode)!==-1;case"keydown":return s.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function Lg(t){return t=t.detail,typeof t=="object"&&"data"in t?t.data:null}var Ao=!1;function qj(t,s){switch(t){case"compositionend":return Lg(s);case"keypress":return s.which!==32?null:(zg=!0,Og);case"textInput":return t=s.data,t===Og&&zg?null:t;default:return null}}function Fj(t,s){if(Ao)return t==="compositionend"||!tf&&Ig(t,s)?(t=kg(),Bl=Wd=Zs=null,Ao=!1,t):null;switch(t){case"paste":return null;case"keypress":if(!(s.ctrlKey||s.altKey||s.metaKey)||s.ctrlKey&&s.altKey){if(s.char&&1=s)return{node:i,offset:s-t};t=u}e:{for(;i;){if(i.nextSibling){i=i.nextSibling;break e}i=i.parentNode}i=void 0}i=Fg(i)}}function Gg(t,s){return t&&s?t===s?!0:t&&t.nodeType===3?!1:s&&s.nodeType===3?Gg(t,s.parentNode):"contains"in t?t.contains(s):t.compareDocumentPosition?!!(t.compareDocumentPosition(s)&16):!1:!1}function Xg(t){t=t!=null&&t.ownerDocument!=null&&t.ownerDocument.defaultView!=null?t.ownerDocument.defaultView:window;for(var s=_o(t.document);s instanceof t.HTMLIFrameElement;){try{var i=typeof s.contentWindow.location.href=="string"}catch{i=!1}if(i)t=s.contentWindow;else break;s=_o(t.document)}return s}function rf(t){var s=t&&t.nodeName&&t.nodeName.toLowerCase();return s&&(s==="input"&&(t.type==="text"||t.type==="search"||t.type==="tel"||t.type==="url"||t.type==="password")||s==="textarea"||t.contentEditable==="true")}var Jj=bs&&"documentMode"in document&&11>=document.documentMode,Mo=null,of=null,ni=null,af=!1;function Zg(t,s,i){var u=i.window===i?i.document:i.nodeType===9?i:i.ownerDocument;af||Mo==null||Mo!==_o(u)||(u=Mo,"selectionStart"in u&&rf(u)?u={start:u.selectionStart,end:u.selectionEnd}:(u=(u.ownerDocument&&u.ownerDocument.defaultView||window).getSelection(),u={anchorNode:u.anchorNode,anchorOffset:u.anchorOffset,focusNode:u.focusNode,focusOffset:u.focusOffset}),ni&&ti(ni,u)||(ni=u,u=Mc(of,"onSelect"),0>=A,p-=A,Ns=1<<32-yt(s)+p|i<v?v:8;var A=k.T,z={};k.T=z,Yf(t,!1,s,i);try{var F=p(),re=k.S;if(re!==null&&re(z,F),F!==null&&typeof F=="object"&&typeof F.then=="function"){var le=l_(F,u);xi(t,s,le,hn(t))}else xi(t,s,u,hn(t))}catch(me){xi(t,s,{then:function(){},status:"rejected",reason:me},hn())}finally{L.p=v,k.T=A}}function m_(){}function qf(t,s,i,u){if(t.tag!==5)throw Error(a(476));var p=Wx(t).queue;Zx(t,p,s,I,i===null?m_:function(){return Kx(t),i(u)})}function Wx(t){var s=t.memoizedState;if(s!==null)return s;s={memoizedState:I,baseState:I,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Es,lastRenderedState:I},next:null};var i={};return s.next={memoizedState:i,baseState:i,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Es,lastRenderedState:i},next:null},t.memoizedState=s,t=t.alternate,t!==null&&(t.memoizedState=s),s}function Kx(t){var s=Wx(t).next.queue;xi(t,s,{},hn())}function Ff(){return Zt(zi)}function Qx(){return Rt().memoizedState}function Jx(){return Rt().memoizedState}function h_(t){for(var s=t.return;s!==null;){switch(s.tag){case 24:case 3:var i=hn();t=Qs(i);var u=Js(s,t,i);u!==null&&(pn(u,s,i),di(u,s,i)),s={cache:wf()},t.payload=s;return}s=s.return}}function p_(t,s,i){var u=hn();i={lane:u,revertLane:0,action:i,hasEagerState:!1,eagerState:null,next:null},uc(t)?t0(s,i):(i=df(t,s,i,u),i!==null&&(pn(i,t,u),n0(i,s,u)))}function e0(t,s,i){var u=hn();xi(t,s,i,u)}function xi(t,s,i,u){var p={lane:u,revertLane:0,action:i,hasEagerState:!1,eagerState:null,next:null};if(uc(t))t0(s,p);else{var v=t.alternate;if(t.lanes===0&&(v===null||v.lanes===0)&&(v=s.lastRenderedReducer,v!==null))try{var A=s.lastRenderedState,z=v(A,i);if(p.hasEagerState=!0,p.eagerState=z,cn(z,A))return Gl(t,s,p,0),gt===null&&Yl(),!1}catch{}finally{}if(i=df(t,s,p,u),i!==null)return pn(i,t,u),n0(i,s,u),!0}return!1}function Yf(t,s,i,u){if(u={lane:2,revertLane:jm(),action:u,hasEagerState:!1,eagerState:null,next:null},uc(t)){if(s)throw Error(a(479))}else s=df(t,i,u,2),s!==null&&pn(s,t,2)}function uc(t){var s=t.alternate;return t===Ze||s!==null&&s===Ze}function t0(t,s){Bo=rc=!0;var i=t.pending;i===null?s.next=s:(s.next=i.next,i.next=s),t.pending=s}function n0(t,s,i){if((i&4194048)!==0){var u=s.lanes;u&=t.pendingLanes,i|=u,s.lanes=i,La(t,i)}}var dc={readContext:Zt,use:ac,useCallback:Ct,useContext:Ct,useEffect:Ct,useImperativeHandle:Ct,useLayoutEffect:Ct,useInsertionEffect:Ct,useMemo:Ct,useReducer:Ct,useRef:Ct,useState:Ct,useDebugValue:Ct,useDeferredValue:Ct,useTransition:Ct,useSyncExternalStore:Ct,useId:Ct,useHostTransitionStatus:Ct,useFormState:Ct,useActionState:Ct,useOptimistic:Ct,useMemoCache:Ct,useCacheRefresh:Ct},s0={readContext:Zt,use:ac,useCallback:function(t,s){return rn().memoizedState=[t,s===void 0?null:s],t},useContext:Zt,useEffect:Bx,useImperativeHandle:function(t,s,i){i=i!=null?i.concat([t]):null,cc(4194308,4,qx.bind(null,s,t),i)},useLayoutEffect:function(t,s){return cc(4194308,4,t,s)},useInsertionEffect:function(t,s){cc(4,2,t,s)},useMemo:function(t,s){var i=rn();s=s===void 0?null:s;var u=t();if(Gr){Nt(!0);try{t()}finally{Nt(!1)}}return i.memoizedState=[u,s],u},useReducer:function(t,s,i){var u=rn();if(i!==void 0){var p=i(s);if(Gr){Nt(!0);try{i(s)}finally{Nt(!1)}}}else p=s;return u.memoizedState=u.baseState=p,t={pending:null,lanes:0,dispatch:null,lastRenderedReducer:t,lastRenderedState:p},u.queue=t,t=t.dispatch=p_.bind(null,Ze,t),[u.memoizedState,t]},useRef:function(t){var s=rn();return t={current:t},s.memoizedState=t},useState:function(t){t=Bf(t);var s=t.queue,i=e0.bind(null,Ze,s);return s.dispatch=i,[t.memoizedState,i]},useDebugValue:Uf,useDeferredValue:function(t,s){var i=rn();return Vf(i,t,s)},useTransition:function(){var t=Bf(!1);return t=Zx.bind(null,Ze,t.queue,!0,!1),rn().memoizedState=t,[!1,t]},useSyncExternalStore:function(t,s,i){var u=Ze,p=rn();if(at){if(i===void 0)throw Error(a(407));i=i()}else{if(i=s(),gt===null)throw Error(a(349));(nt&124)!==0||jx(u,s,i)}p.memoizedState=i;var v={value:i,getSnapshot:s};return p.queue=v,Bx(Ex.bind(null,u,v,t),[t]),u.flags|=2048,Uo(9,lc(),_x.bind(null,u,v,i,s),null),i},useId:function(){var t=rn(),s=gt.identifierPrefix;if(at){var i=Ss,u=Ns;i=(u&~(1<<32-yt(u)-1)).toString(32)+i,s="«"+s+"R"+i,i=oc++,0Ve?(Pt=Oe,Oe=null):Pt=Oe.sibling;var rt=oe(Q,Oe,se[Ve],ce);if(rt===null){Oe===null&&(Oe=Pt);break}t&&Oe&&rt.alternate===null&&s(Q,Oe),X=v(rt,X,Ve),Ke===null?Ce=rt:Ke.sibling=rt,Ke=rt,Oe=Pt}if(Ve===se.length)return i(Q,Oe),at&&Pr(Q,Ve),Ce;if(Oe===null){for(;VeVe?(Pt=Oe,Oe=null):Pt=Oe.sibling;var gr=oe(Q,Oe,rt.value,ce);if(gr===null){Oe===null&&(Oe=Pt);break}t&&Oe&&gr.alternate===null&&s(Q,Oe),X=v(gr,X,Ve),Ke===null?Ce=gr:Ke.sibling=gr,Ke=gr,Oe=Pt}if(rt.done)return i(Q,Oe),at&&Pr(Q,Ve),Ce;if(Oe===null){for(;!rt.done;Ve++,rt=se.next())rt=me(Q,rt.value,ce),rt!==null&&(X=v(rt,X,Ve),Ke===null?Ce=rt:Ke.sibling=rt,Ke=rt);return at&&Pr(Q,Ve),Ce}for(Oe=u(Oe);!rt.done;Ve++,rt=se.next())rt=ae(Oe,Q,Ve,rt.value,ce),rt!==null&&(t&&rt.alternate!==null&&Oe.delete(rt.key===null?Ve:rt.key),X=v(rt,X,Ve),Ke===null?Ce=rt:Ke.sibling=rt,Ke=rt);return t&&Oe.forEach(function(xE){return s(Q,xE)}),at&&Pr(Q,Ve),Ce}function mt(Q,X,se,ce){if(typeof se=="object"&&se!==null&&se.type===S&&se.key===null&&(se=se.props.children),typeof se=="object"&&se!==null){switch(se.$$typeof){case x:e:{for(var Ce=se.key;X!==null;){if(X.key===Ce){if(Ce=se.type,Ce===S){if(X.tag===7){i(Q,X.sibling),ce=p(X,se.props.children),ce.return=Q,Q=ce;break e}}else if(X.elementType===Ce||typeof Ce=="object"&&Ce!==null&&Ce.$$typeof===B&&o0(Ce)===X.type){i(Q,X.sibling),ce=p(X,se.props),vi(ce,se),ce.return=Q,Q=ce;break e}i(Q,X);break}else s(Q,X);X=X.sibling}se.type===S?(ce=$r(se.props.children,Q.mode,ce,se.key),ce.return=Q,Q=ce):(ce=Zl(se.type,se.key,se.props,null,Q.mode,ce),vi(ce,se),ce.return=Q,Q=ce)}return A(Q);case b:e:{for(Ce=se.key;X!==null;){if(X.key===Ce)if(X.tag===4&&X.stateNode.containerInfo===se.containerInfo&&X.stateNode.implementation===se.implementation){i(Q,X.sibling),ce=p(X,se.children||[]),ce.return=Q,Q=ce;break e}else{i(Q,X);break}else s(Q,X);X=X.sibling}ce=hf(se,Q.mode,ce),ce.return=Q,Q=ce}return A(Q);case B:return Ce=se._init,se=Ce(se._payload),mt(Q,X,se,ce)}if(U(se))return qe(Q,X,se,ce);if(G(se)){if(Ce=G(se),typeof Ce!="function")throw Error(a(150));return se=Ce.call(se),Be(Q,X,se,ce)}if(typeof se.then=="function")return mt(Q,X,fc(se),ce);if(se.$$typeof===E)return mt(Q,X,Jl(Q,se),ce);mc(Q,se)}return typeof se=="string"&&se!==""||typeof se=="number"||typeof se=="bigint"?(se=""+se,X!==null&&X.tag===6?(i(Q,X.sibling),ce=p(X,se),ce.return=Q,Q=ce):(i(Q,X),ce=mf(se,Q.mode,ce),ce.return=Q,Q=ce),A(Q)):i(Q,X)}return function(Q,X,se,ce){try{yi=0;var Ce=mt(Q,X,se,ce);return Vo=null,Ce}catch(Oe){if(Oe===ci||Oe===tc)throw Oe;var Ke=un(29,Oe,null,Q.mode);return Ke.lanes=ce,Ke.return=Q,Ke}finally{}}}var qo=a0(!0),i0=a0(!1),En=$(null),Wn=null;function tr(t){var s=t.alternate;V(zt,zt.current&1),V(En,t),Wn===null&&(s===null||$o.current!==null||s.memoizedState!==null)&&(Wn=t)}function l0(t){if(t.tag===22){if(V(zt,zt.current),V(En,t),Wn===null){var s=t.alternate;s!==null&&s.memoizedState!==null&&(Wn=t)}}else nr()}function nr(){V(zt,zt.current),V(En,En.current)}function Cs(t){Y(En),Wn===t&&(Wn=null),Y(zt)}var zt=$(0);function hc(t){for(var s=t;s!==null;){if(s.tag===13){var i=s.memoizedState;if(i!==null&&(i=i.dehydrated,i===null||i.data==="$?"||Im(i)))return s}else if(s.tag===19&&s.memoizedProps.revealOrder!==void 0){if((s.flags&128)!==0)return s}else if(s.child!==null){s.child.return=s,s=s.child;continue}if(s===t)break;for(;s.sibling===null;){if(s.return===null||s.return===t)return null;s=s.return}s.sibling.return=s.return,s=s.sibling}return null}function Gf(t,s,i,u){s=t.memoizedState,i=i(u,s),i=i==null?s:g({},s,i),t.memoizedState=i,t.lanes===0&&(t.updateQueue.baseState=i)}var Xf={enqueueSetState:function(t,s,i){t=t._reactInternals;var u=hn(),p=Qs(u);p.payload=s,i!=null&&(p.callback=i),s=Js(t,p,u),s!==null&&(pn(s,t,u),di(s,t,u))},enqueueReplaceState:function(t,s,i){t=t._reactInternals;var u=hn(),p=Qs(u);p.tag=1,p.payload=s,i!=null&&(p.callback=i),s=Js(t,p,u),s!==null&&(pn(s,t,u),di(s,t,u))},enqueueForceUpdate:function(t,s){t=t._reactInternals;var i=hn(),u=Qs(i);u.tag=2,s!=null&&(u.callback=s),s=Js(t,u,i),s!==null&&(pn(s,t,i),di(s,t,i))}};function c0(t,s,i,u,p,v,A){return t=t.stateNode,typeof t.shouldComponentUpdate=="function"?t.shouldComponentUpdate(u,v,A):s.prototype&&s.prototype.isPureReactComponent?!ti(i,u)||!ti(p,v):!0}function u0(t,s,i,u){t=s.state,typeof s.componentWillReceiveProps=="function"&&s.componentWillReceiveProps(i,u),typeof s.UNSAFE_componentWillReceiveProps=="function"&&s.UNSAFE_componentWillReceiveProps(i,u),s.state!==t&&Xf.enqueueReplaceState(s,s.state,null)}function Xr(t,s){var i=s;if("ref"in s){i={};for(var u in s)u!=="ref"&&(i[u]=s[u])}if(t=t.defaultProps){i===s&&(i=g({},i));for(var p in t)i[p]===void 0&&(i[p]=t[p])}return i}var pc=typeof reportError=="function"?reportError:function(t){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var s=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof t=="object"&&t!==null&&typeof t.message=="string"?String(t.message):String(t),error:t});if(!window.dispatchEvent(s))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",t);return}console.error(t)};function d0(t){pc(t)}function f0(t){console.error(t)}function m0(t){pc(t)}function gc(t,s){try{var i=t.onUncaughtError;i(s.value,{componentStack:s.stack})}catch(u){setTimeout(function(){throw u})}}function h0(t,s,i){try{var u=t.onCaughtError;u(i.value,{componentStack:i.stack,errorBoundary:s.tag===1?s.stateNode:null})}catch(p){setTimeout(function(){throw p})}}function Zf(t,s,i){return i=Qs(i),i.tag=3,i.payload={element:null},i.callback=function(){gc(t,s)},i}function p0(t){return t=Qs(t),t.tag=3,t}function g0(t,s,i,u){var p=i.type.getDerivedStateFromError;if(typeof p=="function"){var v=u.value;t.payload=function(){return p(v)},t.callback=function(){h0(s,i,u)}}var A=i.stateNode;A!==null&&typeof A.componentDidCatch=="function"&&(t.callback=function(){h0(s,i,u),typeof p!="function"&&(lr===null?lr=new Set([this]):lr.add(this));var z=u.stack;this.componentDidCatch(u.value,{componentStack:z!==null?z:""})})}function x_(t,s,i,u,p){if(i.flags|=32768,u!==null&&typeof u=="object"&&typeof u.then=="function"){if(s=i.alternate,s!==null&&ai(s,i,p,!0),i=En.current,i!==null){switch(i.tag){case 13:return Wn===null?vm():i.alternate===null&&_t===0&&(_t=3),i.flags&=-257,i.flags|=65536,i.lanes=p,u===jf?i.flags|=16384:(s=i.updateQueue,s===null?i.updateQueue=new Set([u]):s.add(u),wm(t,u,p)),!1;case 22:return i.flags|=65536,u===jf?i.flags|=16384:(s=i.updateQueue,s===null?(s={transitions:null,markerInstances:null,retryQueue:new Set([u])},i.updateQueue=s):(i=s.retryQueue,i===null?s.retryQueue=new Set([u]):i.add(u)),wm(t,u,p)),!1}throw Error(a(435,i.tag))}return wm(t,u,p),vm(),!1}if(at)return s=En.current,s!==null?((s.flags&65536)===0&&(s.flags|=256),s.flags|=65536,s.lanes=p,u!==xf&&(t=Error(a(422),{cause:u}),oi(Nn(t,i)))):(u!==xf&&(s=Error(a(423),{cause:u}),oi(Nn(s,i))),t=t.current.alternate,t.flags|=65536,p&=-p,t.lanes|=p,u=Nn(u,i),p=Zf(t.stateNode,u,p),Cf(t,p),_t!==4&&(_t=2)),!1;var v=Error(a(520),{cause:u});if(v=Nn(v,i),Ei===null?Ei=[v]:Ei.push(v),_t!==4&&(_t=2),s===null)return!0;u=Nn(u,i),i=s;do{switch(i.tag){case 3:return i.flags|=65536,t=p&-p,i.lanes|=t,t=Zf(i.stateNode,u,t),Cf(i,t),!1;case 1:if(s=i.type,v=i.stateNode,(i.flags&128)===0&&(typeof s.getDerivedStateFromError=="function"||v!==null&&typeof v.componentDidCatch=="function"&&(lr===null||!lr.has(v))))return i.flags|=65536,p&=-p,i.lanes|=p,p=p0(p),g0(p,t,i,u),Cf(i,p),!1}i=i.return}while(i!==null);return!1}var x0=Error(a(461)),$t=!1;function Vt(t,s,i,u){s.child=t===null?i0(s,null,i,u):qo(s,t.child,i,u)}function y0(t,s,i,u,p){i=i.render;var v=s.ref;if("ref"in u){var A={};for(var z in u)z!=="ref"&&(A[z]=u[z])}else A=u;return Fr(s),u=Rf(t,s,i,A,v,p),z=Df(),t!==null&&!$t?(Of(t,s,p),ks(t,s,p)):(at&&z&&pf(s),s.flags|=1,Vt(t,s,u,p),s.child)}function v0(t,s,i,u,p){if(t===null){var v=i.type;return typeof v=="function"&&!ff(v)&&v.defaultProps===void 0&&i.compare===null?(s.tag=15,s.type=v,b0(t,s,v,u,p)):(t=Zl(i.type,null,u,s,s.mode,p),t.ref=s.ref,t.return=s,s.child=t)}if(v=t.child,!sm(t,p)){var A=v.memoizedProps;if(i=i.compare,i=i!==null?i:ti,i(A,u)&&t.ref===s.ref)return ks(t,s,p)}return s.flags|=1,t=ws(v,u),t.ref=s.ref,t.return=s,s.child=t}function b0(t,s,i,u,p){if(t!==null){var v=t.memoizedProps;if(ti(v,u)&&t.ref===s.ref)if($t=!1,s.pendingProps=u=v,sm(t,p))(t.flags&131072)!==0&&($t=!0);else return s.lanes=t.lanes,ks(t,s,p)}return Wf(t,s,i,u,p)}function w0(t,s,i){var u=s.pendingProps,p=u.children,v=t!==null?t.memoizedState:null;if(u.mode==="hidden"){if((s.flags&128)!==0){if(u=v!==null?v.baseLanes|i:i,t!==null){for(p=s.child=t.child,v=0;p!==null;)v=v|p.lanes|p.childLanes,p=p.sibling;s.childLanes=v&~u}else s.childLanes=0,s.child=null;return N0(t,s,u,i)}if((i&536870912)!==0)s.memoizedState={baseLanes:0,cachePool:null},t!==null&&ec(s,v!==null?v.cachePool:null),v!==null?bx(s,v):Af(),l0(s);else return s.lanes=s.childLanes=536870912,N0(t,s,v!==null?v.baseLanes|i:i,i)}else v!==null?(ec(s,v.cachePool),bx(s,v),nr(),s.memoizedState=null):(t!==null&&ec(s,null),Af(),nr());return Vt(t,s,p,i),s.child}function N0(t,s,i,u){var p=Sf();return p=p===null?null:{parent:Ot._currentValue,pool:p},s.memoizedState={baseLanes:i,cachePool:p},t!==null&&ec(s,null),Af(),l0(s),t!==null&&ai(t,s,u,!0),null}function xc(t,s){var i=s.ref;if(i===null)t!==null&&t.ref!==null&&(s.flags|=4194816);else{if(typeof i!="function"&&typeof i!="object")throw Error(a(284));(t===null||t.ref!==i)&&(s.flags|=4194816)}}function Wf(t,s,i,u,p){return Fr(s),i=Rf(t,s,i,u,void 0,p),u=Df(),t!==null&&!$t?(Of(t,s,p),ks(t,s,p)):(at&&u&&pf(s),s.flags|=1,Vt(t,s,i,p),s.child)}function S0(t,s,i,u,p,v){return Fr(s),s.updateQueue=null,i=Nx(s,u,i,p),wx(t),u=Df(),t!==null&&!$t?(Of(t,s,v),ks(t,s,v)):(at&&u&&pf(s),s.flags|=1,Vt(t,s,i,v),s.child)}function j0(t,s,i,u,p){if(Fr(s),s.stateNode===null){var v=Oo,A=i.contextType;typeof A=="object"&&A!==null&&(v=Zt(A)),v=new i(u,v),s.memoizedState=v.state!==null&&v.state!==void 0?v.state:null,v.updater=Xf,s.stateNode=v,v._reactInternals=s,v=s.stateNode,v.props=u,v.state=s.memoizedState,v.refs={},_f(s),A=i.contextType,v.context=typeof A=="object"&&A!==null?Zt(A):Oo,v.state=s.memoizedState,A=i.getDerivedStateFromProps,typeof A=="function"&&(Gf(s,i,A,u),v.state=s.memoizedState),typeof i.getDerivedStateFromProps=="function"||typeof v.getSnapshotBeforeUpdate=="function"||typeof v.UNSAFE_componentWillMount!="function"&&typeof v.componentWillMount!="function"||(A=v.state,typeof v.componentWillMount=="function"&&v.componentWillMount(),typeof v.UNSAFE_componentWillMount=="function"&&v.UNSAFE_componentWillMount(),A!==v.state&&Xf.enqueueReplaceState(v,v.state,null),mi(s,u,v,p),fi(),v.state=s.memoizedState),typeof v.componentDidMount=="function"&&(s.flags|=4194308),u=!0}else if(t===null){v=s.stateNode;var z=s.memoizedProps,F=Xr(i,z);v.props=F;var re=v.context,le=i.contextType;A=Oo,typeof le=="object"&&le!==null&&(A=Zt(le));var me=i.getDerivedStateFromProps;le=typeof me=="function"||typeof v.getSnapshotBeforeUpdate=="function",z=s.pendingProps!==z,le||typeof v.UNSAFE_componentWillReceiveProps!="function"&&typeof v.componentWillReceiveProps!="function"||(z||re!==A)&&u0(s,v,u,A),Ks=!1;var oe=s.memoizedState;v.state=oe,mi(s,u,v,p),fi(),re=s.memoizedState,z||oe!==re||Ks?(typeof me=="function"&&(Gf(s,i,me,u),re=s.memoizedState),(F=Ks||c0(s,i,F,u,oe,re,A))?(le||typeof v.UNSAFE_componentWillMount!="function"&&typeof v.componentWillMount!="function"||(typeof v.componentWillMount=="function"&&v.componentWillMount(),typeof v.UNSAFE_componentWillMount=="function"&&v.UNSAFE_componentWillMount()),typeof v.componentDidMount=="function"&&(s.flags|=4194308)):(typeof v.componentDidMount=="function"&&(s.flags|=4194308),s.memoizedProps=u,s.memoizedState=re),v.props=u,v.state=re,v.context=A,u=F):(typeof v.componentDidMount=="function"&&(s.flags|=4194308),u=!1)}else{v=s.stateNode,Ef(t,s),A=s.memoizedProps,le=Xr(i,A),v.props=le,me=s.pendingProps,oe=v.context,re=i.contextType,F=Oo,typeof re=="object"&&re!==null&&(F=Zt(re)),z=i.getDerivedStateFromProps,(re=typeof z=="function"||typeof v.getSnapshotBeforeUpdate=="function")||typeof v.UNSAFE_componentWillReceiveProps!="function"&&typeof v.componentWillReceiveProps!="function"||(A!==me||oe!==F)&&u0(s,v,u,F),Ks=!1,oe=s.memoizedState,v.state=oe,mi(s,u,v,p),fi();var ae=s.memoizedState;A!==me||oe!==ae||Ks||t!==null&&t.dependencies!==null&&Ql(t.dependencies)?(typeof z=="function"&&(Gf(s,i,z,u),ae=s.memoizedState),(le=Ks||c0(s,i,le,u,oe,ae,F)||t!==null&&t.dependencies!==null&&Ql(t.dependencies))?(re||typeof v.UNSAFE_componentWillUpdate!="function"&&typeof v.componentWillUpdate!="function"||(typeof v.componentWillUpdate=="function"&&v.componentWillUpdate(u,ae,F),typeof v.UNSAFE_componentWillUpdate=="function"&&v.UNSAFE_componentWillUpdate(u,ae,F)),typeof v.componentDidUpdate=="function"&&(s.flags|=4),typeof v.getSnapshotBeforeUpdate=="function"&&(s.flags|=1024)):(typeof v.componentDidUpdate!="function"||A===t.memoizedProps&&oe===t.memoizedState||(s.flags|=4),typeof v.getSnapshotBeforeUpdate!="function"||A===t.memoizedProps&&oe===t.memoizedState||(s.flags|=1024),s.memoizedProps=u,s.memoizedState=ae),v.props=u,v.state=ae,v.context=F,u=le):(typeof v.componentDidUpdate!="function"||A===t.memoizedProps&&oe===t.memoizedState||(s.flags|=4),typeof v.getSnapshotBeforeUpdate!="function"||A===t.memoizedProps&&oe===t.memoizedState||(s.flags|=1024),u=!1)}return v=u,xc(t,s),u=(s.flags&128)!==0,v||u?(v=s.stateNode,i=u&&typeof i.getDerivedStateFromError!="function"?null:v.render(),s.flags|=1,t!==null&&u?(s.child=qo(s,t.child,null,p),s.child=qo(s,null,i,p)):Vt(t,s,i,p),s.memoizedState=v.state,t=s.child):t=ks(t,s,p),t}function _0(t,s,i,u){return ri(),s.flags|=256,Vt(t,s,i,u),s.child}var Kf={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function Qf(t){return{baseLanes:t,cachePool:fx()}}function Jf(t,s,i){return t=t!==null?t.childLanes&~i:0,s&&(t|=Cn),t}function E0(t,s,i){var u=s.pendingProps,p=!1,v=(s.flags&128)!==0,A;if((A=v)||(A=t!==null&&t.memoizedState===null?!1:(zt.current&2)!==0),A&&(p=!0,s.flags&=-129),A=(s.flags&32)!==0,s.flags&=-33,t===null){if(at){if(p?tr(s):nr(),at){var z=jt,F;if(F=z){e:{for(F=z,z=Zn;F.nodeType!==8;){if(!z){z=null;break e}if(F=Hn(F.nextSibling),F===null){z=null;break e}}z=F}z!==null?(s.memoizedState={dehydrated:z,treeContext:Br!==null?{id:Ns,overflow:Ss}:null,retryLane:536870912,hydrationErrors:null},F=un(18,null,null,0),F.stateNode=z,F.return=s,s.child=F,Kt=s,jt=null,F=!0):F=!1}F||Vr(s)}if(z=s.memoizedState,z!==null&&(z=z.dehydrated,z!==null))return Im(z)?s.lanes=32:s.lanes=536870912,null;Cs(s)}return z=u.children,u=u.fallback,p?(nr(),p=s.mode,z=yc({mode:"hidden",children:z},p),u=$r(u,p,i,null),z.return=s,u.return=s,z.sibling=u,s.child=z,p=s.child,p.memoizedState=Qf(i),p.childLanes=Jf(t,A,i),s.memoizedState=Kf,u):(tr(s),em(s,z))}if(F=t.memoizedState,F!==null&&(z=F.dehydrated,z!==null)){if(v)s.flags&256?(tr(s),s.flags&=-257,s=tm(t,s,i)):s.memoizedState!==null?(nr(),s.child=t.child,s.flags|=128,s=null):(nr(),p=u.fallback,z=s.mode,u=yc({mode:"visible",children:u.children},z),p=$r(p,z,i,null),p.flags|=2,u.return=s,p.return=s,u.sibling=p,s.child=u,qo(s,t.child,null,i),u=s.child,u.memoizedState=Qf(i),u.childLanes=Jf(t,A,i),s.memoizedState=Kf,s=p);else if(tr(s),Im(z)){if(A=z.nextSibling&&z.nextSibling.dataset,A)var re=A.dgst;A=re,u=Error(a(419)),u.stack="",u.digest=A,oi({value:u,source:null,stack:null}),s=tm(t,s,i)}else if($t||ai(t,s,i,!1),A=(i&t.childLanes)!==0,$t||A){if(A=gt,A!==null&&(u=i&-i,u=(u&42)!==0?1:Ha(u),u=(u&(A.suspendedLanes|i))!==0?0:u,u!==0&&u!==F.retryLane))throw F.retryLane=u,Do(t,u),pn(A,t,u),x0;z.data==="$?"||vm(),s=tm(t,s,i)}else z.data==="$?"?(s.flags|=192,s.child=t.child,s=null):(t=F.treeContext,jt=Hn(z.nextSibling),Kt=s,at=!0,Ur=null,Zn=!1,t!==null&&(jn[_n++]=Ns,jn[_n++]=Ss,jn[_n++]=Br,Ns=t.id,Ss=t.overflow,Br=s),s=em(s,u.children),s.flags|=4096);return s}return p?(nr(),p=u.fallback,z=s.mode,F=t.child,re=F.sibling,u=ws(F,{mode:"hidden",children:u.children}),u.subtreeFlags=F.subtreeFlags&65011712,re!==null?p=ws(re,p):(p=$r(p,z,i,null),p.flags|=2),p.return=s,u.return=s,u.sibling=p,s.child=u,u=p,p=s.child,z=t.child.memoizedState,z===null?z=Qf(i):(F=z.cachePool,F!==null?(re=Ot._currentValue,F=F.parent!==re?{parent:re,pool:re}:F):F=fx(),z={baseLanes:z.baseLanes|i,cachePool:F}),p.memoizedState=z,p.childLanes=Jf(t,A,i),s.memoizedState=Kf,u):(tr(s),i=t.child,t=i.sibling,i=ws(i,{mode:"visible",children:u.children}),i.return=s,i.sibling=null,t!==null&&(A=s.deletions,A===null?(s.deletions=[t],s.flags|=16):A.push(t)),s.child=i,s.memoizedState=null,i)}function em(t,s){return s=yc({mode:"visible",children:s},t.mode),s.return=t,t.child=s}function yc(t,s){return t=un(22,t,null,s),t.lanes=0,t.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null},t}function tm(t,s,i){return qo(s,t.child,null,i),t=em(s,s.pendingProps.children),t.flags|=2,s.memoizedState=null,t}function C0(t,s,i){t.lanes|=s;var u=t.alternate;u!==null&&(u.lanes|=s),vf(t.return,s,i)}function nm(t,s,i,u,p){var v=t.memoizedState;v===null?t.memoizedState={isBackwards:s,rendering:null,renderingStartTime:0,last:u,tail:i,tailMode:p}:(v.isBackwards=s,v.rendering=null,v.renderingStartTime=0,v.last=u,v.tail=i,v.tailMode=p)}function k0(t,s,i){var u=s.pendingProps,p=u.revealOrder,v=u.tail;if(Vt(t,s,u.children,i),u=zt.current,(u&2)!==0)u=u&1|2,s.flags|=128;else{if(t!==null&&(t.flags&128)!==0)e:for(t=s.child;t!==null;){if(t.tag===13)t.memoizedState!==null&&C0(t,i,s);else if(t.tag===19)C0(t,i,s);else if(t.child!==null){t.child.return=t,t=t.child;continue}if(t===s)break e;for(;t.sibling===null;){if(t.return===null||t.return===s)break e;t=t.return}t.sibling.return=t.return,t=t.sibling}u&=1}switch(V(zt,u),p){case"forwards":for(i=s.child,p=null;i!==null;)t=i.alternate,t!==null&&hc(t)===null&&(p=i),i=i.sibling;i=p,i===null?(p=s.child,s.child=null):(p=i.sibling,i.sibling=null),nm(s,!1,p,i,v);break;case"backwards":for(i=null,p=s.child,s.child=null;p!==null;){if(t=p.alternate,t!==null&&hc(t)===null){s.child=p;break}t=p.sibling,p.sibling=i,i=p,p=t}nm(s,!0,i,null,v);break;case"together":nm(s,!1,null,null,void 0);break;default:s.memoizedState=null}return s.child}function ks(t,s,i){if(t!==null&&(s.dependencies=t.dependencies),ir|=s.lanes,(i&s.childLanes)===0)if(t!==null){if(ai(t,s,i,!1),(i&s.childLanes)===0)return null}else return null;if(t!==null&&s.child!==t.child)throw Error(a(153));if(s.child!==null){for(t=s.child,i=ws(t,t.pendingProps),s.child=i,i.return=s;t.sibling!==null;)t=t.sibling,i=i.sibling=ws(t,t.pendingProps),i.return=s;i.sibling=null}return s.child}function sm(t,s){return(t.lanes&s)!==0?!0:(t=t.dependencies,!!(t!==null&&Ql(t)))}function y_(t,s,i){switch(s.tag){case 3:ie(s,s.stateNode.containerInfo),Ws(s,Ot,t.memoizedState.cache),ri();break;case 27:case 5:be(s);break;case 4:ie(s,s.stateNode.containerInfo);break;case 10:Ws(s,s.type,s.memoizedProps.value);break;case 13:var u=s.memoizedState;if(u!==null)return u.dehydrated!==null?(tr(s),s.flags|=128,null):(i&s.child.childLanes)!==0?E0(t,s,i):(tr(s),t=ks(t,s,i),t!==null?t.sibling:null);tr(s);break;case 19:var p=(t.flags&128)!==0;if(u=(i&s.childLanes)!==0,u||(ai(t,s,i,!1),u=(i&s.childLanes)!==0),p){if(u)return k0(t,s,i);s.flags|=128}if(p=s.memoizedState,p!==null&&(p.rendering=null,p.tail=null,p.lastEffect=null),V(zt,zt.current),u)break;return null;case 22:case 23:return s.lanes=0,w0(t,s,i);case 24:Ws(s,Ot,t.memoizedState.cache)}return ks(t,s,i)}function A0(t,s,i){if(t!==null)if(t.memoizedProps!==s.pendingProps)$t=!0;else{if(!sm(t,i)&&(s.flags&128)===0)return $t=!1,y_(t,s,i);$t=(t.flags&131072)!==0}else $t=!1,at&&(s.flags&1048576)!==0&&ox(s,Kl,s.index);switch(s.lanes=0,s.tag){case 16:e:{t=s.pendingProps;var u=s.elementType,p=u._init;if(u=p(u._payload),s.type=u,typeof u=="function")ff(u)?(t=Xr(u,t),s.tag=1,s=j0(null,s,u,t,i)):(s.tag=0,s=Wf(null,s,u,t,i));else{if(u!=null){if(p=u.$$typeof,p===T){s.tag=11,s=y0(null,s,u,t,i);break e}else if(p===O){s.tag=14,s=v0(null,s,u,t,i);break e}}throw s=P(u)||u,Error(a(306,s,""))}}return s;case 0:return Wf(t,s,s.type,s.pendingProps,i);case 1:return u=s.type,p=Xr(u,s.pendingProps),j0(t,s,u,p,i);case 3:e:{if(ie(s,s.stateNode.containerInfo),t===null)throw Error(a(387));u=s.pendingProps;var v=s.memoizedState;p=v.element,Ef(t,s),mi(s,u,null,i);var A=s.memoizedState;if(u=A.cache,Ws(s,Ot,u),u!==v.cache&&bf(s,[Ot],i,!0),fi(),u=A.element,v.isDehydrated)if(v={element:u,isDehydrated:!1,cache:A.cache},s.updateQueue.baseState=v,s.memoizedState=v,s.flags&256){s=_0(t,s,u,i);break e}else if(u!==p){p=Nn(Error(a(424)),s),oi(p),s=_0(t,s,u,i);break e}else{switch(t=s.stateNode.containerInfo,t.nodeType){case 9:t=t.body;break;default:t=t.nodeName==="HTML"?t.ownerDocument.body:t}for(jt=Hn(t.firstChild),Kt=s,at=!0,Ur=null,Zn=!0,i=i0(s,null,u,i),s.child=i;i;)i.flags=i.flags&-3|4096,i=i.sibling}else{if(ri(),u===p){s=ks(t,s,i);break e}Vt(t,s,u,i)}s=s.child}return s;case 26:return xc(t,s),t===null?(i=Dy(s.type,null,s.pendingProps,null))?s.memoizedState=i:at||(i=s.type,t=s.pendingProps,u=Rc(ue.current).createElement(i),u[Ht]=s,u[Xt]=t,Ft(u,i,t),Mt(u),s.stateNode=u):s.memoizedState=Dy(s.type,t.memoizedProps,s.pendingProps,t.memoizedState),null;case 27:return be(s),t===null&&at&&(u=s.stateNode=My(s.type,s.pendingProps,ue.current),Kt=s,Zn=!0,p=jt,dr(s.type)?(Lm=p,jt=Hn(u.firstChild)):jt=p),Vt(t,s,s.pendingProps.children,i),xc(t,s),t===null&&(s.flags|=4194304),s.child;case 5:return t===null&&at&&((p=u=jt)&&(u=Y_(u,s.type,s.pendingProps,Zn),u!==null?(s.stateNode=u,Kt=s,jt=Hn(u.firstChild),Zn=!1,p=!0):p=!1),p||Vr(s)),be(s),p=s.type,v=s.pendingProps,A=t!==null?t.memoizedProps:null,u=v.children,Dm(p,v)?u=null:A!==null&&Dm(p,A)&&(s.flags|=32),s.memoizedState!==null&&(p=Rf(t,s,u_,null,null,i),zi._currentValue=p),xc(t,s),Vt(t,s,u,i),s.child;case 6:return t===null&&at&&((t=i=jt)&&(i=G_(i,s.pendingProps,Zn),i!==null?(s.stateNode=i,Kt=s,jt=null,t=!0):t=!1),t||Vr(s)),null;case 13:return E0(t,s,i);case 4:return ie(s,s.stateNode.containerInfo),u=s.pendingProps,t===null?s.child=qo(s,null,u,i):Vt(t,s,u,i),s.child;case 11:return y0(t,s,s.type,s.pendingProps,i);case 7:return Vt(t,s,s.pendingProps,i),s.child;case 8:return Vt(t,s,s.pendingProps.children,i),s.child;case 12:return Vt(t,s,s.pendingProps.children,i),s.child;case 10:return u=s.pendingProps,Ws(s,s.type,u.value),Vt(t,s,u.children,i),s.child;case 9:return p=s.type._context,u=s.pendingProps.children,Fr(s),p=Zt(p),u=u(p),s.flags|=1,Vt(t,s,u,i),s.child;case 14:return v0(t,s,s.type,s.pendingProps,i);case 15:return b0(t,s,s.type,s.pendingProps,i);case 19:return k0(t,s,i);case 31:return u=s.pendingProps,i=s.mode,u={mode:u.mode,children:u.children},t===null?(i=yc(u,i),i.ref=s.ref,s.child=i,i.return=s,s=i):(i=ws(t.child,u),i.ref=s.ref,s.child=i,i.return=s,s=i),s;case 22:return w0(t,s,i);case 24:return Fr(s),u=Zt(Ot),t===null?(p=Sf(),p===null&&(p=gt,v=wf(),p.pooledCache=v,v.refCount++,v!==null&&(p.pooledCacheLanes|=i),p=v),s.memoizedState={parent:u,cache:p},_f(s),Ws(s,Ot,p)):((t.lanes&i)!==0&&(Ef(t,s),mi(s,null,null,i),fi()),p=t.memoizedState,v=s.memoizedState,p.parent!==u?(p={parent:u,cache:u},s.memoizedState=p,s.lanes===0&&(s.memoizedState=s.updateQueue.baseState=p),Ws(s,Ot,u)):(u=v.cache,Ws(s,Ot,u),u!==p.cache&&bf(s,[Ot],i,!0))),Vt(t,s,s.pendingProps.children,i),s.child;case 29:throw s.pendingProps}throw Error(a(156,s.tag))}function As(t){t.flags|=4}function M0(t,s){if(s.type!=="stylesheet"||(s.state.loading&4)!==0)t.flags&=-16777217;else if(t.flags|=16777216,!Hy(s)){if(s=En.current,s!==null&&((nt&4194048)===nt?Wn!==null:(nt&62914560)!==nt&&(nt&536870912)===0||s!==Wn))throw ui=jf,mx;t.flags|=8192}}function vc(t,s){s!==null&&(t.flags|=4),t.flags&16384&&(s=t.tag!==22?ot():536870912,t.lanes|=s,Xo|=s)}function bi(t,s){if(!at)switch(t.tailMode){case"hidden":s=t.tail;for(var i=null;s!==null;)s.alternate!==null&&(i=s),s=s.sibling;i===null?t.tail=null:i.sibling=null;break;case"collapsed":i=t.tail;for(var u=null;i!==null;)i.alternate!==null&&(u=i),i=i.sibling;u===null?s||t.tail===null?t.tail=null:t.tail.sibling=null:u.sibling=null}}function St(t){var s=t.alternate!==null&&t.alternate.child===t.child,i=0,u=0;if(s)for(var p=t.child;p!==null;)i|=p.lanes|p.childLanes,u|=p.subtreeFlags&65011712,u|=p.flags&65011712,p.return=t,p=p.sibling;else for(p=t.child;p!==null;)i|=p.lanes|p.childLanes,u|=p.subtreeFlags,u|=p.flags,p.return=t,p=p.sibling;return t.subtreeFlags|=u,t.childLanes=i,s}function v_(t,s,i){var u=s.pendingProps;switch(gf(s),s.tag){case 31:case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return St(s),null;case 1:return St(s),null;case 3:return i=s.stateNode,u=null,t!==null&&(u=t.memoizedState.cache),s.memoizedState.cache!==u&&(s.flags|=2048),_s(Ot),ge(),i.pendingContext&&(i.context=i.pendingContext,i.pendingContext=null),(t===null||t.child===null)&&(si(s)?As(s):t===null||t.memoizedState.isDehydrated&&(s.flags&256)===0||(s.flags|=1024,lx())),St(s),null;case 26:return i=s.memoizedState,t===null?(As(s),i!==null?(St(s),M0(s,i)):(St(s),s.flags&=-16777217)):i?i!==t.memoizedState?(As(s),St(s),M0(s,i)):(St(s),s.flags&=-16777217):(t.memoizedProps!==u&&As(s),St(s),s.flags&=-16777217),null;case 27:we(s),i=ue.current;var p=s.type;if(t!==null&&s.stateNode!=null)t.memoizedProps!==u&&As(s);else{if(!u){if(s.stateNode===null)throw Error(a(166));return St(s),null}t=W.current,si(s)?ax(s):(t=My(p,u,i),s.stateNode=t,As(s))}return St(s),null;case 5:if(we(s),i=s.type,t!==null&&s.stateNode!=null)t.memoizedProps!==u&&As(s);else{if(!u){if(s.stateNode===null)throw Error(a(166));return St(s),null}if(t=W.current,si(s))ax(s);else{switch(p=Rc(ue.current),t){case 1:t=p.createElementNS("http://www.w3.org/2000/svg",i);break;case 2:t=p.createElementNS("http://www.w3.org/1998/Math/MathML",i);break;default:switch(i){case"svg":t=p.createElementNS("http://www.w3.org/2000/svg",i);break;case"math":t=p.createElementNS("http://www.w3.org/1998/Math/MathML",i);break;case"script":t=p.createElement("div"),t.innerHTML="