chore: import upstream snapshot with attribution
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
# Declarative Package (agent-framework-declarative)
YAML/JSON-based declarative agent and workflow definitions.
## Main Classes
- **`AgentFactory`** - Creates agents from declarative definitions
- **`WorkflowFactory`** - Creates workflows from declarative definitions
- **`WorkflowState`** - State management for declarative workflows
- **`ProviderTypeMapping`** - Maps provider types to implementations
- **`HttpRequestHandler`** / **`DefaultHttpRequestHandler`** - Pluggable HTTP transport for the `HttpRequestAction` declarative action (configured via `WorkflowFactory(http_request_handler=...)`)
- **`MCPToolHandler`** / **`DefaultMCPToolHandler`** - Pluggable MCP transport for the `InvokeMcpTool` declarative action (configured via `WorkflowFactory(mcp_tool_handler=...)`)
- **`DeclarativeLoaderError`** / **`ProviderLookupError`** / **`DeclarativeWorkflowError`** / **`DeclarativeActionError`** - Error types
## External Input Handling
- **`ExternalInputRequest`** / **`ExternalInputResponse`** - Human-in-the-loop support
- **`AgentExternalInputRequest`** / **`AgentExternalInputResponse`** - Agent-level input requests
## Usage
```python
from agent_framework.declarative import AgentFactory, WorkflowFactory
# Create agent from YAML file
agent_factory = AgentFactory()
agent = agent_factory.create_agent_from_yaml_path("agent.yaml")
# Create workflow from YAML file
workflow_factory = WorkflowFactory()
workflow = workflow_factory.create_workflow_from_yaml_path("workflow.yaml")
```
## Import Path
```python
from agent_framework.declarative import AgentFactory, WorkflowFactory
# or directly:
from agent_framework_declarative import AgentFactory
```
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
+23
View File
@@ -0,0 +1,23 @@
# Get Started with Microsoft Agent Framework Declarative
Please install this package via pip:
```bash
pip install agent-framework-declarative --pre
```
## Release stage
This package ships at two different stability levels:
- **Declarative workflows** (`WorkflowFactory`, executors, handlers, and the
`_workflows` surface) are at **release-candidate** stability and may receive only
minor refinements before GA.
- **Declarative agents** (`AgentFactory` and the YAML agent loading/parsing path:
`DeclarativeLoaderError`, `ProviderLookupError`, `ProviderTypeMapping`) are
**experimental** and may change or be removed in future versions without notice.
Using any of these symbols emits an `ExperimentalWarning` on first use.
## Declarative features
The declarative packages provides support for building agents based on a declarative yaml specification.
@@ -0,0 +1,71 @@
# Copyright (c) Microsoft. All rights reserved.
"""Declarative specification support for Microsoft Agent Framework.
Release stage:
* The declarative-workflows surface (``WorkflowFactory``, executors, handlers,
etc.) is at release-candidate stability.
* The declarative-agents surface (``AgentFactory`` and the YAML agent
loading/parsing path: ``DeclarativeLoaderError``, ``ProviderLookupError``,
``ProviderTypeMapping``) is *experimental* and may change or be removed in
future versions without notice. Using these symbols emits an
``ExperimentalWarning`` on first use.
"""
from importlib import metadata
from ._loader import AgentFactory, DeclarativeLoaderError, ProviderLookupError, ProviderTypeMapping
from ._workflows import (
AgentExternalInputRequest,
AgentExternalInputResponse,
DeclarativeActionError,
DeclarativeWorkflowError,
DefaultHttpRequestHandler,
DefaultMCPToolHandler,
ExternalInputRequest,
ExternalInputResponse,
HttpRequestHandler,
HttpRequestInfo,
HttpRequestResult,
MCPToolApprovalRequest,
MCPToolHandler,
MCPToolInvocation,
MCPToolResult,
ToolApprovalRequest,
ToolApprovalResponse,
WorkflowFactory,
WorkflowState,
)
try:
__version__ = metadata.version(__name__)
except metadata.PackageNotFoundError:
__version__ = "0.0.0" # Fallback for development mode
__all__ = [
"AgentExternalInputRequest",
"AgentExternalInputResponse",
"AgentFactory",
"DeclarativeActionError",
"DeclarativeLoaderError",
"DeclarativeWorkflowError",
"DefaultHttpRequestHandler",
"DefaultMCPToolHandler",
"ExternalInputRequest",
"ExternalInputResponse",
"HttpRequestHandler",
"HttpRequestInfo",
"HttpRequestResult",
"MCPToolApprovalRequest",
"MCPToolHandler",
"MCPToolInvocation",
"MCPToolResult",
"ProviderLookupError",
"ProviderTypeMapping",
"ToolApprovalRequest",
"ToolApprovalResponse",
"WorkflowFactory",
"WorkflowState",
"__version__",
]
@@ -0,0 +1,868 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import sys
from collections.abc import Callable, Mapping
from pathlib import Path
from typing import Any, cast
import yaml
from agent_framework import (
Agent,
SupportsChatGetResponse,
)
from agent_framework import (
FunctionTool as AFFunctionTool,
)
from agent_framework._feature_stage import (
ExperimentalFeature,
experimental,
)
from agent_framework.exceptions import AgentException
from dotenv import load_dotenv
from ._models import (
AnonymousConnection,
ApiKeyConnection,
CodeInterpreterTool,
FileSearchTool,
FunctionTool,
McpServerToolSpecifyApprovalMode,
McpTool,
Model,
ModelOptions,
PromptAgent,
ReferenceConnection,
RemoteConnection,
Tool,
WebSearchTool,
_safe_mode_context, # type: ignore[reportPrivateUsage]
agent_schema_dispatch,
)
if sys.version_info >= (3, 11):
from typing import TypedDict # pragma: no cover
else:
from typing_extensions import TypedDict # pragma: no cover
@experimental(feature_id=ExperimentalFeature.DECLARATIVE_AGENTS)
class ProviderTypeMapping(TypedDict, total=True):
package: str
name: str
model_field: str
endpoint_field: str | None
api_key_field: str | None
PROVIDER_TYPE_OBJECT_MAPPING: dict[str, ProviderTypeMapping] = {
"AzureOpenAI": {
"package": "agent_framework.openai",
"name": "OpenAIChatClient",
"model_field": "model",
"endpoint_field": "azure_endpoint",
"api_key_field": "api_key",
},
"AzureOpenAI.Chat": {
"package": "agent_framework.openai",
"name": "OpenAIChatCompletionClient",
"model_field": "model",
"endpoint_field": "azure_endpoint",
"api_key_field": "api_key",
},
"AzureOpenAI.Responses": {
"package": "agent_framework.openai",
"name": "OpenAIChatClient",
"model_field": "model",
"endpoint_field": "azure_endpoint",
"api_key_field": "api_key",
},
"Foundry": {
"package": "agent_framework.foundry",
"name": "FoundryChatClient",
"model_field": "model",
"endpoint_field": "project_endpoint",
"api_key_field": None,
},
"OpenAI.Chat": {
"package": "agent_framework.openai",
"name": "OpenAIChatCompletionClient",
"model_field": "model",
"endpoint_field": "base_url",
"api_key_field": "api_key",
},
"OpenAI.Responses": {
"package": "agent_framework.openai",
"name": "OpenAIChatClient",
"model_field": "model",
"endpoint_field": "base_url",
"api_key_field": "api_key",
},
"OpenAI": {
"package": "agent_framework.openai",
"name": "OpenAIChatClient",
"model_field": "model",
"endpoint_field": "base_url",
"api_key_field": "api_key",
},
"Foundry.Chat": {
"package": "agent_framework.foundry",
"name": "FoundryChatClient",
"model_field": "model",
"endpoint_field": "project_endpoint",
"api_key_field": None,
},
"Anthropic.Chat": {
"package": "agent_framework.anthropic",
"name": "AnthropicChatClient",
"model_field": "model",
"endpoint_field": None,
"api_key_field": "api_key",
},
}
@experimental(feature_id=ExperimentalFeature.DECLARATIVE_AGENTS)
class DeclarativeLoaderError(AgentException):
"""Exception raised for errors in the declarative loader."""
pass
@experimental(feature_id=ExperimentalFeature.DECLARATIVE_AGENTS)
class ProviderLookupError(DeclarativeLoaderError):
"""Exception raised for errors in provider type lookup."""
pass
@experimental(feature_id=ExperimentalFeature.DECLARATIVE_AGENTS)
class AgentFactory:
"""Factory for creating Agent instances from declarative YAML definitions.
AgentFactory parses YAML agent definitions (PromptAgent kind) and creates
configured Agent 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("Hello!", stream=True):
print(event)
.. code-block:: python
from agent_framework.openai import OpenAIChatClient
from agent_framework_declarative import AgentFactory
# With pre-configured chat client
client = OpenAIChatClient()
factory = AgentFactory(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,
*,
client: SupportsChatGetResponse | None = None,
bindings: Mapping[str, Any] | None = None,
connections: Mapping[str, Any] | None = None,
client_kwargs: Mapping[str, Any] | None = None,
additional_mappings: Mapping[str, ProviderTypeMapping] | None = None,
default_provider: str = "Foundry",
safe_mode: bool = True,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
) -> None:
"""Create the agent factory.
Args:
client: An optional SupportsChatGetResponse instance to use as a dependency.
This will be passed to the Agent 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:
..code-block:: python
additional_mappings = {
"Provider.ApiType": {
"package": "package.name",
"name": "ClassName",
"model_field": "field_name_in_constructor",
"endpoint_field": "endpoint_kwarg_name_or_null",
"api_key_field": "api_key_kwarg_name_or_null",
},
...
}
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
SupportsChatGetResponse implementation, and model_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 "Foundry", which uses the FoundryChatClient.
safe_mode: Whether to run in safe mode, default is True.
When safe_mode is True, environment variables are not accessible in the powerfx expressions.
You can still use environment variables, but through the constructors of the classes.
Which means you must make sure you are using the standard env variable names of the classes
you are using and not custom ones and remove the powerfx statements that start with `=Env.`.
Only when you trust the source of your yaml files, you can set safe_mode to False
via the AgentFactory constructor.
env_file_path: The path to the .env file to load environment variables from.
env_file_encoding: The encoding of the .env file, defaults to 'utf-8'.
Examples:
.. code-block:: python
from agent_framework_declarative import AgentFactory
# Minimal initialization
factory = AgentFactory()
.. code-block:: python
from agent_framework.openai import OpenAIChatClient
from agent_framework_declarative import AgentFactory
# With shared chat client
client = OpenAIChatClient()
factory = AgentFactory(
client=client,
env_file_path=".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_field": "model",
},
},
)
"""
self.client = client
self.bindings = bindings
self.connections = connections
self.client_kwargs = client_kwargs or {}
self.additional_mappings = additional_mappings or {}
self.default_provider: str = default_provider
self.safe_mode = safe_mode
load_dotenv(dotenv_path=env_file_path, encoding=env_file_encoding)
def create_agent_from_yaml_path(self, yaml_path: str | Path) -> Agent:
"""Create a Agent from a YAML file path.
This method does the following things:
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 Agent instance with the configured properties.
Args:
yaml_path: Path to the YAML file representation of a PromptAgent.
Returns:
The ``Agent`` instance created from the YAML file.
Raises:
DeclarativeLoaderError: If the YAML 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
factory = AgentFactory()
agent = factory.create_agent_from_yaml_path("agents/support_agent.yaml")
# Execute the agent
async for event in agent.run("Help me with my order", stream=True):
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)
if not yaml_path.exists():
raise DeclarativeLoaderError(f"YAML file not found at path: {yaml_path}")
with open(yaml_path) as f:
yaml_str = f.read()
return self.create_agent_from_yaml(yaml_str)
def create_agent_from_yaml(self, yaml_str: str) -> Agent:
"""Create a Agent from a YAML string.
This method does the following things:
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 Agent instance with the configured properties.
Args:
yaml_str: YAML string representation of a PromptAgent.
Returns:
The ``Agent`` instance created from the YAML string.
Raises:
DeclarativeLoaderError: If the YAML 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
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]) -> Agent:
"""Create a Agent 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 Agent instance with the configured properties.
Args:
agent_def: Dictionary representation of a PromptAgent.
Returns:
The `Agent` 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)
"""
# Set safe_mode context before parsing YAML to control PowerFx environment variable access
_safe_mode_context.set(self.safe_mode)
prompt_agent = agent_schema_dispatch(agent_def)
if not isinstance(prompt_agent, PromptAgent):
raise DeclarativeLoaderError("Only definitions for a PromptAgent are supported for agent creation.")
# Step 1: Create the ChatClient
client = self._get_client(prompt_agent)
# Step 2: Get the chat options
chat_options = self._parse_chat_options(prompt_agent.model)
if tools := self._parse_tools(prompt_agent.tools):
chat_options["tools"] = tools
if output_schema := prompt_agent.outputSchema:
chat_options["response_format"] = output_schema.to_json_schema()
# Step 3: Create the agent instance
return Agent(
client=client,
name=prompt_agent.name,
description=prompt_agent.description,
instructions=prompt_agent.instructions,
default_options=chat_options, # type: ignore[arg-type]
)
async def create_agent_from_yaml_path_async(self, yaml_path: str | Path) -> Agent:
"""Async version: Create a Agent from a YAML file path.
This is the async counterpart to ``create_agent_from_dict`` and is useful when
the rest of your setup is already async.
Args:
yaml_path: Path to the YAML file representation of a PromptAgent.
Returns:
The ``Agent`` instance created from the YAML file.
Examples:
.. code-block:: python
from agent_framework_declarative import AgentFactory
factory = AgentFactory(
client_kwargs={"credential": credential},
default_provider="Foundry",
)
agent = await factory.create_agent_from_yaml_path_async("agent.yaml")
"""
if not isinstance(yaml_path, Path):
yaml_path = Path(yaml_path)
if not yaml_path.exists():
raise DeclarativeLoaderError(f"YAML file not found at path: {yaml_path}")
yaml_str = yaml_path.read_text()
return await self.create_agent_from_yaml_async(yaml_str)
async def create_agent_from_yaml_async(self, yaml_str: str) -> Agent:
"""Async version: Create a Agent from a YAML string.
Use this method when the surrounding call site is already async and you
want to build an agent directly from YAML text.
Args:
yaml_str: YAML string representation of a PromptAgent.
Returns:
The ``Agent`` instance created from the YAML string.
Examples:
.. code-block:: python
from agent_framework_declarative import AgentFactory
yaml_content = '''
kind: Prompt
name: MyAgent
instructions: You are a helpful assistant.
model:
id: gpt-4o
provider: Foundry
'''
factory = AgentFactory(client_kwargs={"credential": credential})
agent = await factory.create_agent_from_yaml_async(yaml_content)
"""
return await self.create_agent_from_dict_async(yaml.safe_load(yaml_str))
async def create_agent_from_dict_async(self, agent_def: dict[str, Any]) -> Agent:
"""Async version: Create a Agent from a dictionary definition.
This is the async counterpart to ``create_agent_from_dict`` and is useful when
the rest of your setup is already async.
Args:
agent_def: Dictionary representation of a PromptAgent.
Returns:
The ``Agent`` instance created from the dictionary.
Examples:
.. code-block:: python
from agent_framework_declarative import AgentFactory
agent_def = {
"kind": "Prompt",
"name": "MyAgent",
"instructions": "You are a helpful assistant.",
"model": {
"id": "gpt-4o",
"provider": "Foundry",
},
}
factory = AgentFactory(client_kwargs={"credential": credential})
agent = await factory.create_agent_from_dict_async(agent_def)
"""
# Set safe_mode context before parsing YAML to control PowerFx environment variable access
_safe_mode_context.set(self.safe_mode)
prompt_agent = agent_schema_dispatch(agent_def)
if not isinstance(prompt_agent, PromptAgent):
raise DeclarativeLoaderError("Only definitions for a PromptAgent are supported for agent creation.")
client = self._get_client(prompt_agent)
chat_options = self._parse_chat_options(prompt_agent.model)
if tools := self._parse_tools(prompt_agent.tools):
chat_options["tools"] = tools
if output_schema := prompt_agent.outputSchema:
chat_options["response_format"] = output_schema.to_json_schema()
return Agent(
client=client,
name=prompt_agent.name,
description=prompt_agent.description,
instructions=prompt_agent.instructions,
default_options=chat_options, # type: ignore[arg-type]
)
async def _create_agent_with_provider(self, prompt_agent: PromptAgent, mapping: ProviderTypeMapping) -> Agent:
"""Create an Agent through a provider object that exposes ``create_agent``.
This remains available as an internal escape hatch for provider-style custom mappings
that return a fully constructed ``Agent`` rather than a chat client.
"""
module_name = mapping["package"]
class_name = mapping["name"]
module = __import__(module_name, fromlist=[class_name])
provider_class = getattr(module, class_name)
provider_kwargs: dict[str, Any] = {}
provider_kwargs.update(self.client_kwargs)
endpoint_field = mapping.get("endpoint_field")
api_key_field = mapping.get("api_key_field", "api_key")
if prompt_agent.model and prompt_agent.model.connection:
match prompt_agent.model.connection:
case ApiKeyConnection():
if api_key_field:
provider_kwargs[api_key_field] = prompt_agent.model.connection.apiKey
if prompt_agent.model.connection.endpoint and endpoint_field:
provider_kwargs[endpoint_field] = prompt_agent.model.connection.endpoint
case RemoteConnection() | AnonymousConnection():
if prompt_agent.model.connection.endpoint and endpoint_field:
provider_kwargs[endpoint_field] = prompt_agent.model.connection.endpoint
case ReferenceConnection():
pass
provider = provider_class(**provider_kwargs)
tools = self._parse_tools(prompt_agent.tools) if prompt_agent.tools else None
default_options: dict[str, Any] | None = None
if prompt_agent.outputSchema:
default_options = {"response_format": prompt_agent.outputSchema.to_json_schema()}
return cast(
Agent,
await provider.create_agent(
name=prompt_agent.name,
model=prompt_agent.model.id if prompt_agent.model else None,
instructions=prompt_agent.instructions,
description=prompt_agent.description,
tools=tools,
default_options=default_options,
),
)
def _get_client(self, prompt_agent: PromptAgent) -> SupportsChatGetResponse:
"""Create the SupportsChatGetResponse instance based on the PromptAgent model."""
if not prompt_agent.model:
# if no model is defined, use the supplied client
if self.client:
return self.client
raise DeclarativeLoaderError(
"ChatClient must be provided to create agent from PromptAgent, "
"alternatively define a model in the PromptAgent."
)
mapping = self._retrieve_provider_configuration(prompt_agent.model)
setup_dict: dict[str, Any] = {}
setup_dict.update(self.client_kwargs)
endpoint_field = mapping.get("endpoint_field")
api_key_field = mapping.get("api_key_field", "api_key")
# parse connections
if prompt_agent.model.connection:
match prompt_agent.model.connection:
case ApiKeyConnection():
if api_key_field:
setup_dict[api_key_field] = prompt_agent.model.connection.apiKey
elif prompt_agent.model.connection.apiKey:
raise DeclarativeLoaderError(
f"{mapping['name']} does not support API key-based model connections."
)
if prompt_agent.model.connection.endpoint:
if not endpoint_field:
raise DeclarativeLoaderError(
f"{mapping['name']} does not support endpoint-based model connections."
)
setup_dict[endpoint_field] = prompt_agent.model.connection.endpoint
case RemoteConnection() | AnonymousConnection():
if prompt_agent.model.connection.endpoint:
if not endpoint_field:
raise DeclarativeLoaderError(
f"{mapping['name']} does not support endpoint-based model connections."
)
setup_dict[endpoint_field] = prompt_agent.model.connection.endpoint
case ReferenceConnection():
if not self.connections:
raise ValueError("Connections must be provided to resolve ReferenceConnection")
# find the referenced connection
if prompt_agent.model.connection.name and (
value := self.connections.get(prompt_agent.model.connection.name)
):
setup_dict[prompt_agent.model.connection.name] = value
else:
raise ValueError(
f"ReferenceConnection with name {prompt_agent.model.connection.name} not found in provided "
"connections."
)
# Any client we create, needs a model.id
if not prompt_agent.model.id:
# if prompt_agent.model is defined, but no id, use the supplied client
if self.client:
return self.client
# or raise, since we cannot create a client without a model
raise DeclarativeLoaderError(
"ChatClient must be provided to create agent from PromptAgent, or define model.id in the PromptAgent."
)
# if provider is defined, use that, if possible with apiType, fallback to default_provider
module_name = mapping["package"]
class_name = mapping["name"]
module = __import__(module_name, fromlist=[class_name])
agent_class = getattr(module, class_name)
setup_dict[mapping["model_field"]] = prompt_agent.model.id
return agent_class(**setup_dict)
def _parse_chat_options(self, model: Model | None) -> dict[str, Any]:
"""Parse ModelOptions into chat options dictionary."""
chat_options: dict[str, Any] = {}
if not model or not model.options or not isinstance(model.options, ModelOptions):
return chat_options
options = model.options
if options.frequencyPenalty is not None:
chat_options["frequency_penalty"] = options.frequencyPenalty
if options.presencePenalty is not None:
chat_options["presence_penalty"] = options.presencePenalty
if options.maxOutputTokens is not None:
chat_options["max_tokens"] = options.maxOutputTokens
if options.temperature is not None:
chat_options["temperature"] = options.temperature
if options.topP is not None:
chat_options["top_p"] = options.topP
if options.seed is not None:
chat_options["seed"] = options.seed
if options.stopSequences:
chat_options["stop"] = options.stopSequences
if options.allowMultipleToolCalls is not None:
chat_options["allow_multiple_tool_calls"] = options.allowMultipleToolCalls
if (chat_tool_mode := options.additionalProperties.pop("chatToolMode", None)) is not None:
chat_options["tool_choice"] = chat_tool_mode
if options.additionalProperties:
chat_options["additional_chat_options"] = options.additionalProperties
return chat_options
def _parse_tools(self, tools: list[Tool] | None) -> list[AFFunctionTool | dict[str, Any]] | None:
"""Parse tool resources into AFFunctionTool instances or dict-based tools."""
if not tools:
return None
return [self._parse_tool(tool_resource) for tool_resource in tools]
def _parse_tool(self, tool_resource: Tool) -> AFFunctionTool | dict[str, Any]:
"""Parse a single tool resource into an AFFunctionTool instance."""
match tool_resource:
case FunctionTool():
func: Callable[..., Any] | None = None
if self.bindings and tool_resource.bindings:
for binding in tool_resource.bindings:
if binding.name and (func := self.bindings.get(binding.name)):
break
return AFFunctionTool(
name=tool_resource.name, # type: ignore
description=tool_resource.description, # type: ignore
input_model=tool_resource.parameters.to_json_schema() if tool_resource.parameters else None,
func=func,
)
case WebSearchTool():
result: dict[str, Any] = {"type": "web_search_preview"}
if tool_resource.description:
result["description"] = tool_resource.description
if tool_resource.options:
result.update(tool_resource.options)
return result
case FileSearchTool():
result = {
"type": "file_search",
"vector_store_ids": tool_resource.vectorStoreIds or [],
}
if tool_resource.maximumResultCount is not None:
result["max_num_results"] = tool_resource.maximumResultCount
if tool_resource.description:
result["description"] = tool_resource.description
if tool_resource.ranker is not None:
result["ranker"] = tool_resource.ranker
if tool_resource.scoreThreshold is not None:
result["score_threshold"] = tool_resource.scoreThreshold
if tool_resource.filters:
result["filters"] = tool_resource.filters
return result
case CodeInterpreterTool():
result = {"type": "code_interpreter"}
if tool_resource.fileIds:
result["file_ids"] = tool_resource.fileIds
if tool_resource.description:
result["description"] = tool_resource.description
return result
case McpTool():
result = {
"type": "mcp",
"server_label": tool_resource.name.replace(" ", "_") if tool_resource.name else "",
"server_url": str(tool_resource.url) if tool_resource.url else "",
}
if tool_resource.description:
result["server_description"] = tool_resource.description
if tool_resource.allowedTools:
result["allowed_tools"] = list(tool_resource.allowedTools)
# Handle approval mode
if tool_resource.approvalMode is not None:
if tool_resource.approvalMode.kind == "always":
result["require_approval"] = "always"
elif tool_resource.approvalMode.kind == "never":
result["require_approval"] = "never"
elif isinstance(tool_resource.approvalMode, McpServerToolSpecifyApprovalMode):
approval_config: dict[str, Any] = {}
if tool_resource.approvalMode.alwaysRequireApprovalTools:
approval_config["always"] = {
"tool_names": list(tool_resource.approvalMode.alwaysRequireApprovalTools)
}
if tool_resource.approvalMode.neverRequireApprovalTools:
approval_config["never"] = {
"tool_names": list(tool_resource.approvalMode.neverRequireApprovalTools)
}
if approval_config:
result["require_approval"] = approval_config
# Handle connection settings
if tool_resource.connection is not None:
match tool_resource.connection:
case ApiKeyConnection():
if tool_resource.connection.apiKey:
result["headers"] = {"Authorization": f"Bearer {tool_resource.connection.apiKey}"}
case RemoteConnection():
result["project_connection_id"] = tool_resource.connection.name
case ReferenceConnection():
result["project_connection_id"] = tool_resource.connection.name
case AnonymousConnection():
pass
case _:
raise ValueError(f"Unsupported connection kind: {tool_resource.connection.kind}")
return result
case _:
raise ValueError(f"Unsupported tool kind: {tool_resource.kind}")
def _retrieve_provider_configuration(self, model: Model) -> ProviderTypeMapping:
"""Retrieve the provider configuration based on the model's provider and apiType.
If only provider is specified, it will be used.
If both provider and apiType are specified, both will be used.
If neither is specified, the default_provider will be used.
Args:
model: The Model instance containing provider and apiType information.
Returns:
A dictionary containing the package, name, and model_field for the provider.
Raises:
ProviderLookupError: If the provider type is not supported or can't be found.
"""
class_lookup = (
f"{model.provider}.{model.apiType}"
if model.apiType
else f"{model.provider}"
if model.provider
else self.default_provider
)
if class_lookup in self.additional_mappings:
return self.additional_mappings[class_lookup]
if class_lookup not in PROVIDER_TYPE_OBJECT_MAPPING:
raise ProviderLookupError(f"Unsupported provider type: {class_lookup}")
return PROVIDER_TYPE_OBJECT_MAPPING[class_lookup]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,167 @@
# 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 ._errors import DeclarativeActionError, DeclarativeWorkflowError
from ._executors_agents import (
AGENT_ACTION_EXECUTORS,
AGENT_REGISTRY_KEY,
TOOL_REGISTRY_KEY,
AgentExternalInputRequest,
AgentExternalInputResponse,
AgentResult,
ExternalLoopState,
InvokeAzureAgentExecutor,
)
from ._executors_basic import (
BASIC_ACTION_EXECUTORS,
ClearAllVariablesExecutor,
CreateConversationExecutor,
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,
ExternalInputRequest,
ExternalInputResponse,
QuestionExecutor,
RequestExternalInputExecutor,
)
from ._executors_http import (
HTTP_ACTION_EXECUTORS,
HttpRequestActionExecutor,
)
from ._executors_mcp import (
MCP_ACTION_EXECUTORS,
InvokeMcpToolActionExecutor,
MCPToolApprovalRequest,
)
from ._executors_tools import (
FUNCTION_TOOL_REGISTRY_KEY,
TOOL_ACTION_EXECUTORS,
BaseToolExecutor,
InvokeFunctionToolExecutor,
ToolApprovalRequest,
ToolApprovalResponse,
ToolInvocationResult,
)
from ._factory import WorkflowFactory
from ._http_handler import (
DefaultHttpRequestHandler,
HttpRequestHandler,
HttpRequestInfo,
HttpRequestResult,
)
from ._mcp_handler import (
DefaultMCPToolHandler,
MCPToolHandler,
MCPToolInvocation,
MCPToolResult,
)
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",
"FUNCTION_TOOL_REGISTRY_KEY",
"HTTP_ACTION_EXECUTORS",
"MCP_ACTION_EXECUTORS",
"TOOL_ACTION_EXECUTORS",
"TOOL_REGISTRY_KEY",
"ActionComplete",
"ActionTrigger",
"AgentExternalInputRequest",
"AgentExternalInputResponse",
"AgentResult",
"BaseToolExecutor",
"BreakLoopExecutor",
"ClearAllVariablesExecutor",
"ContinueLoopExecutor",
"ConversationData",
"CreateConversationExecutor",
"DeclarativeActionError",
"DeclarativeActionExecutor",
"DeclarativeMessage",
"DeclarativeStateData",
"DeclarativeWorkflowBuilder",
"DeclarativeWorkflowError",
"DeclarativeWorkflowState",
"DefaultHttpRequestHandler",
"DefaultMCPToolHandler",
"EndConversationExecutor",
"EndWorkflowExecutor",
"ExternalInputRequest",
"ExternalInputResponse",
"ExternalLoopState",
"ForeachInitExecutor",
"ForeachNextExecutor",
"HttpRequestActionExecutor",
"HttpRequestHandler",
"HttpRequestInfo",
"HttpRequestResult",
"InvokeAzureAgentExecutor",
"InvokeFunctionToolExecutor",
"InvokeMcpToolActionExecutor",
"JoinExecutor",
"LoopControl",
"LoopIterationResult",
"MCPToolApprovalRequest",
"MCPToolHandler",
"MCPToolInvocation",
"MCPToolResult",
"QuestionExecutor",
"RequestExternalInputExecutor",
"ResetVariableExecutor",
"SendActivityExecutor",
"SetMultipleVariablesExecutor",
"SetTextVariableExecutor",
"SetValueExecutor",
"SetVariableExecutor",
"ToolApprovalRequest",
"ToolApprovalResponse",
"ToolInvocationResult",
"WorkflowFactory",
"WorkflowState",
]
@@ -0,0 +1,38 @@
# Copyright (c) Microsoft. All rights reserved.
"""Error types for declarative workflow executor modules.
This module exists so that executor modules and the builder (e.g.
``_executors_http``, ``_declarative_builder``) can raise declarative-specific
exceptions without importing from ``_factory``. ``_factory`` imports
``_declarative_builder`` which imports the executor modules; pulling
:class:`DeclarativeWorkflowError` from ``_factory`` into an executor or
builder module would therefore introduce a circular import.
"""
from __future__ import annotations
from agent_framework.exceptions import WorkflowException
class DeclarativeWorkflowError(WorkflowException):
"""Raised for build-time / factory-level declarative workflow errors.
Used for YAML parsing/validation issues, missing configuration (e.g. an
HTTP request handler not supplied for a workflow that contains an
``HttpRequestAction``), and other errors detected before workflow
execution begins.
"""
pass
class DeclarativeActionError(WorkflowException):
"""Raised when a declarative action fails at run time.
Used by executor modules for runtime failures (e.g. transport errors,
non-2xx responses from :class:`HttpRequestActionExecutor`). Build-time and
factory-level errors continue to use :class:`DeclarativeWorkflowError`.
"""
pass
@@ -0,0 +1,574 @@
# 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.
"""
import uuid
from collections.abc import Mapping
from typing import Any, cast
from agent_framework 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, Mapping):
path = variable.get("path") # type: ignore[reportUnknownVariableType]
return path if isinstance(path, str) else None
fallback_path = action_def.get("path")
return fallback_path if isinstance(fallback_path, str) else None
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 = state.eval_if_expression(value)
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 = state.eval_if_expression(value)
state.set(path, evaluated_value)
await ctx.send_message(ActionComplete())
class CreateConversationExecutor(DeclarativeActionExecutor):
"""Executor for the CreateConversation action.
Generates a unique conversation ID and initialises a conversation entry
in ``System.conversations``. The generated ID is stored at the state
path specified by the ``conversationId`` parameter (if provided).
"""
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Handle the CreateConversation action."""
state = await self._ensure_state_initialized(ctx, trigger)
generated_id = str(uuid.uuid4())
# Store the generated ID at the requested path (e.g. "Local.myConvId")
conversation_id_path = _get_variable_path(self._action_def, "conversationId")
if conversation_id_path:
state.set(conversation_id_path, generated_id)
# Initialise the conversation entry in System.conversations
conversations: dict[str, Any] = state.get("System.conversations") or {}
conversations[generated_id] = {
"id": generated_id,
"messages": [],
}
state.set("System.conversations", conversations)
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 = state.eval_if_expression(text)
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 = cast(
list[Mapping[str, Any]],
self._action_def.get("assignments") if isinstance(self._action_def.get("assignments"), list) else [],
)
for assignment in assignments:
if not isinstance(assignment, Mapping):
continue
variable = assignment.get("variable")
path: str | None
if isinstance(variable, str):
path = variable
elif isinstance(variable, Mapping):
path_value = variable.get("path") # type: ignore[reportUnknownMemberType]
path = path_value if isinstance(path_value, str) else None
else:
fallback_path = assignment.get("path")
path = fallback_path if isinstance(fallback_path, str) else None
value = assignment.get("value")
if path:
evaluated_value = state.eval_if_expression(value)
state.set(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
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 Local variables
state_data = state.get_state_data()
state_data["Local"] = {}
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
if isinstance(activity, Mapping):
text: Any = activity.get("text", "") # type: ignore[reportUnknownMemberType]
else:
text = activity
if isinstance(text, str):
# First evaluate any =expression syntax
text = state.eval_if_expression(text)
# Then interpolate any {Variable.Path} template syntax
if isinstance(text, str):
text = state.interpolate_string(text)
# Yield the text as workflow output
if text:
await ctx.yield_output(str(text)) # type: ignore[reportUnknownArgumentType]
await ctx.send_message(ActionComplete())
class EditTableExecutor(DeclarativeActionExecutor):
"""Executor for the EditTable action.
Performs operations on a table (list) variable such as add, remove, or clear.
This is equivalent to the .NET EditTable action.
YAML example:
- kind: EditTable
table: Local.Items
operation: add # add, remove, clear
value: =Local.NewItem
index: 0 # optional, for insert at position
"""
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Handle the EditTable action."""
state = await self._ensure_state_initialized(ctx, trigger)
table_path = self._action_def.get("table") or _get_variable_path(self._action_def, "variable")
operation = self._action_def.get("operation", "add").lower()
value = self._action_def.get("value")
index = self._action_def.get("index")
if table_path:
# Get current table value
current_table_value = state.get(table_path)
current_table: list[Any]
if current_table_value is None:
current_table = []
elif isinstance(current_table_value, list):
current_table = list(current_table_value) # type: ignore[reportUnknownArgumentType]
else:
current_table = [current_table_value]
if operation == "add" or operation == "insert":
evaluated_value = state.eval_if_expression(value)
if index is not None:
evaluated_index = state.eval_if_expression(index)
idx = int(evaluated_index) if evaluated_index is not None else len(current_table)
current_table.insert(idx, evaluated_value)
else:
current_table.append(evaluated_value)
elif operation == "remove":
if value is not None:
# Remove by value
evaluated_value = state.eval_if_expression(value)
if evaluated_value in current_table:
current_table.remove(evaluated_value)
elif index is not None:
# Remove by index
evaluated_index = state.eval_if_expression(index)
idx = int(evaluated_index) if evaluated_index is not None else -1
if 0 <= idx < len(current_table):
current_table.pop(idx)
elif operation == "clear":
current_table = []
elif operation == "set" or operation == "update":
# Update item at index
if index is not None:
evaluated_value = state.eval_if_expression(value)
evaluated_index = state.eval_if_expression(index)
idx = int(evaluated_index) if evaluated_index is not None else 0
if 0 <= idx < len(current_table):
current_table[idx] = evaluated_value
state.set(table_path, current_table)
await ctx.send_message(ActionComplete())
class EditTableV2Executor(DeclarativeActionExecutor):
"""Executor for the EditTableV2 action.
Enhanced table editing with more operations and better record support.
This is equivalent to the .NET EditTableV2 action.
YAML example:
- kind: EditTableV2
table: Local.Records
operation: addOrUpdate # add, remove, clear, addOrUpdate, filter
item: =Local.NewRecord
key: id # for addOrUpdate, the field to match on
condition: =item.status = "active" # for filter operation
"""
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Handle the EditTableV2 action."""
state = await self._ensure_state_initialized(ctx, trigger)
table_path = self._action_def.get("table") or _get_variable_path(self._action_def, "variable")
operation = self._action_def.get("operation", "add").lower()
item = self._action_def.get("item") or self._action_def.get("value")
key_field = self._action_def.get("key")
index = self._action_def.get("index")
if table_path:
# Get current table value
current_table_value = state.get(table_path)
current_table: list[Any]
if current_table_value is None:
current_table = []
elif isinstance(current_table_value, list):
current_table = list(current_table_value) # type: ignore[reportUnknownArgumentType]
else:
current_table = [current_table_value]
if operation == "add":
evaluated_item = state.eval_if_expression(item)
if index is not None:
evaluated_index = state.eval_if_expression(index)
idx = int(evaluated_index) if evaluated_index is not None else len(current_table)
current_table.insert(idx, evaluated_item)
else:
current_table.append(evaluated_item)
elif operation == "remove":
if item is not None:
evaluated_item = state.eval_if_expression(item)
if key_field and isinstance(evaluated_item, dict):
# Remove by key match
evaluated_item_dict = cast(dict[str, Any], evaluated_item)
key_value = evaluated_item_dict.get(key_field)
current_table = [
r
for r in current_table
if not (isinstance(r, dict) and cast(dict[str, Any], r).get(key_field) == key_value)
]
elif evaluated_item in current_table:
current_table.remove(evaluated_item)
elif index is not None:
evaluated_index = state.eval_if_expression(index)
idx = int(evaluated_index) if evaluated_index is not None else -1
if 0 <= idx < len(current_table):
current_table.pop(idx)
elif operation == "clear":
current_table = []
elif operation == "addorupdate":
evaluated_item = state.eval_if_expression(item)
if key_field and isinstance(evaluated_item, dict):
key_value = evaluated_item.get(key_field) # type: ignore[reportUnknownArgumentType]
# Find existing item with same key
found_idx = -1
for i, r in enumerate(current_table):
if isinstance(r, dict) and cast(dict[str, Any], r).get(key_field) == key_value:
found_idx = i
break
if found_idx >= 0:
# Update existing
current_table[found_idx] = evaluated_item
else:
# Add new
current_table.append(evaluated_item)
else:
# No key field - just add
current_table.append(evaluated_item)
elif operation == "update":
evaluated_item = state.eval_if_expression(item)
if index is not None:
evaluated_index = state.eval_if_expression(index)
idx = int(evaluated_index) if evaluated_index is not None else 0
if 0 <= idx < len(current_table):
current_table[idx] = evaluated_item
elif key_field and isinstance(evaluated_item, dict):
key_value = evaluated_item.get(key_field) # type: ignore[reportUnknownArgumentType]
for i, r in enumerate(current_table):
if isinstance(r, dict) and cast(dict[str, Any], r).get(key_field) == key_value:
current_table[i] = evaluated_item
break
state.set(table_path, current_table)
await ctx.send_message(ActionComplete())
class ParseValueExecutor(DeclarativeActionExecutor):
"""Executor for the ParseValue action.
Parses a value expression and optionally converts it to a target type.
This is equivalent to the .NET ParseValue action.
YAML example:
- kind: ParseValue
variable: Local.ParsedData
value: =System.LastMessage.Text
valueType: object # optional: string, number, boolean, object, array
"""
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Handle the ParseValue action."""
state = await self._ensure_state_initialized(ctx, trigger)
path = _get_variable_path(self._action_def)
value = self._action_def.get("value")
value_type = self._action_def.get("valueType")
if path and value is not None:
# Evaluate the value expression
evaluated_value = state.eval_if_expression(value)
# Convert to target type if specified
if value_type:
evaluated_value = self._convert_to_type(evaluated_value, value_type)
state.set(path, evaluated_value)
await ctx.send_message(ActionComplete())
def _convert_to_type(self, value: Any, target_type: str) -> Any:
"""Convert a value to the specified target type.
Args:
value: The value to convert
target_type: Target type (string, number, boolean, object, array)
Returns:
The converted value
"""
import json
target_type = target_type.lower()
if target_type == "string":
if value is None:
return ""
return str(value)
if target_type in ("number", "int", "integer", "float", "decimal"):
if value is None:
return 0
if isinstance(value, str):
# Try to parse as number
try:
if "." in value:
return float(value)
return int(value)
except ValueError:
return 0
return float(value) if isinstance(value, (int, float)) else 0
if target_type in ("boolean", "bool"):
if value is None:
return False
if isinstance(value, str):
return value.lower() in ("true", "yes", "1", "on")
return bool(value)
if target_type in ("object", "record"):
if value is None:
return {}
if isinstance(value, dict):
return cast(dict[str, Any], value)
if isinstance(value, str):
try:
parsed = json.loads(value)
if isinstance(parsed, dict):
return cast(dict[str, Any], parsed)
return {"value": parsed}
except json.JSONDecodeError:
return {"value": value}
return {"value": value}
if target_type in ("array", "table", "list"):
if value is None:
return []
if isinstance(value, list):
return cast(list[Any], value)
if isinstance(value, str):
try:
parsed = json.loads(value)
if isinstance(parsed, list):
return cast(list[Any], parsed)
return [parsed]
except json.JSONDecodeError:
return [value]
return [value]
# Unknown type - return as-is
return value
# Mapping of action kinds to executor classes
BASIC_ACTION_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
"CreateConversation": CreateConversationExecutor,
"SetValue": SetValueExecutor,
"SetVariable": SetVariableExecutor,
"SetTextVariable": SetTextVariableExecutor,
"SetMultipleVariables": SetMultipleVariablesExecutor,
"ResetVariable": ResetVariableExecutor,
"ClearAllVariables": ClearAllVariablesExecutor,
"SendActivity": SendActivityExecutor,
"ParseValue": ParseValueExecutor,
"EditTable": EditTableExecutor,
"EditTableV2": EditTableV2Executor,
}
@@ -0,0 +1,461 @@
# 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/ConditionGroup: 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 State + 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 import (
Message,
WorkflowContext,
handler,
)
from ._declarative_base import (
ActionComplete,
ActionTrigger,
ConditionResult,
DeclarativeActionExecutor,
LoopControl,
LoopIterationResult,
)
# Keys for loop state in State
LOOP_STATE_KEY = "_declarative_loop_state"
# Index value indicating the else/default branch
ELSE_BRANCH_INDEX = -1
class ConditionGroupEvaluatorExecutor(DeclarativeActionExecutor):
"""Evaluates conditions for ConditionGroup 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 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 = 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 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 = 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 State 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)
items_expr = self._action_def.get("source")
items_raw: Any = 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 = 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),
}
state.set_state_data(state_data)
if items:
# Bind the current item and (when requested) the index under the Local scope.
item_var = f"Local.{self._action_def.get('itemName', 'item')}"
index_var = (
f"Local.{self._action_def.get('indexName', 'index')}" if "indexName" in self._action_def else None
)
state.set(item_var, items[0])
if index_var:
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 = 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
state.set_state_data(state_data)
# Rebind the current item and (when requested) the index under the Local scope.
item_var = f"Local.{self._action_def.get('itemName', 'item')}"
index_var = (
f"Local.{self._action_def.get('indexName', 'index')}" if "indexName" in self._action_def else None
)
state.set(item_var, items[current_index])
if index_var:
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]
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.state)
if control.action == "break":
# Clean up loop state and signal done
state_data = 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]
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/ConditionGroup 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
| list[Message]
| ActionTrigger
| ActionComplete
| ConditionResult
| LoopIterationResult,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Simply pass through to continue the workflow."""
await self._ensure_state_initialized(ctx, trigger)
await ctx.send_message(ActionComplete())
class CancelDialogExecutor(DeclarativeActionExecutor):
"""Executor for CancelDialog action.
Cancels the current dialog/workflow, equivalent to .NET CancelDialog.
This terminates execution similarly to EndWorkflow.
"""
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Cancel the current dialog/workflow."""
# CancelDialog terminates execution without continuing
# Similar to EndWorkflow but semantically different (cancellation vs completion)
pass
class CancelAllDialogsExecutor(DeclarativeActionExecutor):
"""Executor for CancelAllDialogs action.
Cancels all dialogs in the execution stack, equivalent to .NET CancelAllDialogs.
This terminates the entire workflow execution.
"""
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Cancel all dialogs/workflows."""
# CancelAllDialogs terminates all execution
pass
# 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,
"CancelDialog": CancelDialogExecutor,
"CancelAllDialogs": CancelAllDialogsExecutor,
}
@@ -0,0 +1,243 @@
# Copyright (c) Microsoft. All rights reserved.
"""External input executors for declarative workflows.
These executors handle interactions that require external input (user questions
and external integrations), using the request_info pattern to pause the workflow
and wait for responses.
"""
import uuid
from dataclasses import dataclass, field
from typing import Any, cast
from agent_framework import (
WorkflowContext,
handler,
response_handler,
)
from ._declarative_base import (
ActionComplete,
DeclarativeActionExecutor,
)
def _get_prompt_text(action_def: dict[str, Any], primary_key: str, fallback_key: str) -> Any:
"""Return the prompt text from an action definition.
Accepts a nested ``{primary_key: {"text": ...}}`` mapping, a bare
string under ``primary_key``, or a top-level ``fallback_key`` value.
"""
match action_def.get(primary_key):
case {"text": text}:
return text
case str() as text:
return text
case _:
return action_def.get(fallback_key, "")
def _get_output_path(action_def: dict[str, Any], default: str) -> str:
"""Return the state path where the action result should be written.
Looks at ``variable``, then ``output.property``, then top-level
``property``, falling back to ``default``.
"""
output = action_def.get("output")
nested = cast(dict[str, Any], output).get("property") if isinstance(output, dict) else None
return action_def.get("variable") or nested or action_def.get("property") or default
@dataclass
class ExternalInputRequest:
"""Request for external input (triggers workflow pause).
Aligns with .NET ExternalInputRequest pattern. Used by Question and
RequestExternalInput executors to signal that user input is needed.
The workflow will pause via request_info and wait for an ExternalInputResponse.
Attributes:
request_id: Unique identifier for this request.
message: The prompt or question to display to the user.
request_type: A free-form discriminator describing the kind of input
being requested. ``QuestionExecutor`` emits ``"question"`` and
``RequestExternalInputExecutor`` defaults to ``"external"``; callers
may supply any other string via the ``requestType`` field on a
``RequestExternalInput`` action (e.g. ``"approval"``) and it is
propagated unchanged.
metadata: Additional context (choices, output_property, timeout, etc.).
"""
request_id: str
message: str
request_type: str = "external"
metadata: dict[str, Any] = field(default_factory=dict) # type: ignore
@dataclass
class ExternalInputResponse:
"""Response to an ExternalInputRequest.
Provided by the caller to resume workflow execution with user input.
Attributes:
user_input: The user's text response.
value: Optional typed value (e.g., bool for confirmations, selected choice).
"""
user_input: str
value: Any = None
class QuestionExecutor(DeclarativeActionExecutor):
"""Executor that asks the user a question and waits for a response.
Uses the request_info pattern to pause execution until the user provides an answer.
The response is stored in workflow state at the configured output property.
"""
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Ask the question and wait for a response."""
state = await self._ensure_state_initialized(ctx, trigger)
question_text = _get_prompt_text(self._action_def, primary_key="question", fallback_key="text")
output_property = _get_output_path(self._action_def, default="Local.answer")
default_value = self._action_def.get("default", self._action_def.get("defaultValue"))
choices = self._action_def.get("choices", [])
allow_free_text = self._action_def.get("allowFreeText", True)
evaluated_question = 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)})
# Store output property in shared state for response handler
ctx.state.set("_question_output_property", output_property)
ctx.state.set("_question_default_value", default_value)
# Request external input - workflow pauses here
await ctx.request_info(
ExternalInputRequest(
request_id=str(uuid.uuid4()),
message=str(evaluated_question),
request_type="question",
metadata={
"output_property": output_property,
"choices": choices_data,
"allow_free_text": allow_free_text,
"default_value": default_value,
},
),
ExternalInputResponse,
)
@response_handler
async def handle_response(
self,
original_request: ExternalInputRequest,
response: ExternalInputResponse,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Handle the user's response to the question."""
state = self._get_state(ctx.state)
output_property = original_request.metadata.get("output_property", "Local.answer")
answer = response.value if response.value is not None else response.user_input
if output_property:
state.set(output_property, answer)
await ctx.send_message(ActionComplete())
class RequestExternalInputExecutor(DeclarativeActionExecutor):
"""Executor that requests external input/approval.
Used for 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],
) -> None:
"""Request external input."""
state = await self._ensure_state_initialized(ctx, trigger)
message = _get_prompt_text(self._action_def, primary_key="prompt", fallback_key="message")
output_property = _get_output_path(self._action_def, default="Local.externalInput")
default_value = self._action_def.get("default")
request_type = self._action_def.get("requestType", "external")
timeout_seconds = self._action_def.get("timeout")
required_fields = self._action_def.get("requiredFields", [])
metadata = self._action_def.get("metadata", {})
evaluated_message = state.eval_if_expression(message)
# Build request metadata
request_metadata: dict[str, Any] = {
**metadata,
"output_property": output_property,
"required_fields": required_fields,
"default_value": default_value,
}
if timeout_seconds:
request_metadata["timeout_seconds"] = timeout_seconds
# Request external input - workflow pauses here
await ctx.request_info(
ExternalInputRequest(
request_id=str(uuid.uuid4()),
message=str(evaluated_message),
request_type=request_type,
metadata=request_metadata,
),
ExternalInputResponse,
)
@response_handler
async def handle_response(
self,
original_request: ExternalInputRequest,
response: ExternalInputResponse,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Handle the external input response."""
state = self._get_state(ctx.state)
output_property = original_request.metadata.get("output_property", "Local.externalInput")
# Store the response value or user_input
result = response.value if response.value is not None else response.user_input
if output_property:
state.set(output_property, result)
await ctx.send_message(ActionComplete())
# Mapping of external input action kinds to executor classes
EXTERNAL_INPUT_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
"Question": QuestionExecutor,
"RequestExternalInput": RequestExternalInputExecutor,
}
@@ -0,0 +1,417 @@
# Copyright (c) Microsoft. All rights reserved.
"""Executor for the ``HttpRequestAction`` declarative action.
Mirrors the .NET ``HttpRequestExecutor``: dispatches an HTTP request through the
configured :class:`HttpRequestHandler`, parses the response body, and assigns
the parsed body and response headers to the declared state paths.
Security note: response bodies can echo secrets and may be very large. Diagnostic
messages produced for non-2xx responses truncate the body to 256 characters and
collapse CR/LF/TAB to spaces (parity with .NET ``FormatBodyForDiagnostics``).
"""
from __future__ import annotations
import json
import logging
from collections.abc import Mapping
from typing import Any
import httpx
from agent_framework import (
Message,
WorkflowContext,
handler,
)
from ._declarative_base import (
ActionComplete,
DeclarativeActionExecutor,
DeclarativeWorkflowState,
)
from ._errors import DeclarativeActionError
from ._http_handler import HttpRequestHandler, HttpRequestInfo, HttpRequestResult
__all__ = [
"HTTP_ACTION_EXECUTORS",
"HttpRequestActionExecutor",
]
logger = logging.getLogger(__name__)
_MAX_BODY_DIAGNOSTIC_LENGTH = 256
_BODY_TRUNCATION_SUFFIX = " \u2026 [truncated]"
# Body discriminator aliases. Long forms match the .NET object-model type
# names so YAML produced by .NET round-trips. Short forms are the .NET YAML
# convention used in test fixtures.
_BODY_KIND_JSON = {"json", "JsonRequestContent"}
_BODY_KIND_RAW = {"raw", "RawRequestContent"}
_BODY_KIND_NONE = {"none", "NoRequestContent"}
def _get_path(action_def: Mapping[str, Any], key: str) -> str | None:
"""Extract a state path from ``response``/``responseHeaders`` field.
Supports two YAML shapes (matches .NET serialization round-trips):
- ``response: Local.MyVar`` (plain string).
- ``response: { path: Local.MyVar }`` (object form).
"""
value = action_def.get(key)
if isinstance(value, str):
return value or None
if isinstance(value, Mapping):
path = value.get("path") # type: ignore[reportUnknownMemberType, reportUnknownVariableType]
return path if isinstance(path, str) and path else None
return None
def _format_body_for_diagnostics(body: str | None) -> str:
"""Truncate and sanitise a response body for inclusion in error messages.
Mirrors the .NET ``FormatBodyForDiagnostics`` helper:
- Empty/None -> empty string.
- Replaces CR/LF/TAB with spaces.
- Truncates to 256 chars with a unicode-ellipsis ``[truncated]`` suffix.
"""
if not body:
return ""
truncated = len(body) > _MAX_BODY_DIAGNOSTIC_LENGTH
head = body[:_MAX_BODY_DIAGNOSTIC_LENGTH] if truncated else body
sanitized = head.replace("\r", " ").replace("\n", " ").replace("\t", " ")
return sanitized + _BODY_TRUNCATION_SUFFIX if truncated else sanitized
def _parse_response_body(body: str | None) -> Any:
"""Parse an HTTP response body the same way the .NET executor does.
JSON-first: if the body parses as JSON, the parsed value is returned. Other
bodies are returned as the raw string. Empty/None bodies return ``None``.
"""
if body is None or body == "":
return None
try:
return json.loads(body)
except json.JSONDecodeError:
return body
def _format_query_value(value: Any) -> str | None:
"""Format a query-parameter value for URL inclusion.
Mirrors .NET ``FormatQueryValue``: ``None`` is dropped, ``bool`` becomes
lower-case ``"true"``/``"false"``, numerics use invariant ``str()``, and
other values fall through to ``str()``.
"""
if value is None:
return None
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, str):
return value
return str(value)
def _get_messages_path(state: DeclarativeWorkflowState, conversation_id_expr: str | None) -> str | None:
"""Return the configured conversation messages path, if any.
Returns ``System.conversations.{evaluated_id}.messages`` when a
``conversation_id_expr`` is configured and evaluates to a non-empty value.
Returns ``None`` when no conversation id expression is configured or when
the expression evaluates to ``None`` or an empty string (matches .NET
``GetConversationId`` behaviour where empty becomes ``null`` and the
response is not appended).
"""
if not conversation_id_expr:
return None
evaluated = state.eval_if_expression(conversation_id_expr)
if evaluated is None or (isinstance(evaluated, str) and not evaluated):
return None
return f"System.conversations.{evaluated}.messages"
class HttpRequestActionExecutor(DeclarativeActionExecutor):
"""Executor for the ``HttpRequestAction`` declarative action.
Dispatches through the supplied :class:`HttpRequestHandler` and:
- Parses the response body (JSON-first, raw string fall-back).
- Assigns the parsed body to ``response`` path (if configured).
- Folds multi-value response headers (comma-joined) and assigns them to
``responseHeaders`` path (if configured).
- On 2xx with non-empty body and a configured ``conversationId``, appends
an Assistant :class:`agent_framework.Message` to
``System.conversations.{id}.messages``.
- On non-2xx, still publishes ``responseHeaders`` (diagnostic) and raises
:class:`DeclarativeActionError` with a status-coded message containing a
truncated/sanitised body preview.
Transport errors (``httpx.TimeoutException``, ``TimeoutError``,
``httpx.HTTPError``) become :class:`DeclarativeActionError`. ``CancelledError``
is intentionally NOT caught so that workflow cancellation propagates.
"""
def __init__(
self,
action_def: dict[str, Any],
*,
id: str | None = None,
http_request_handler: HttpRequestHandler,
) -> None:
"""Create an HTTP request action executor.
Args:
action_def: Parsed ``HttpRequestAction`` YAML dict.
id: Optional executor id (defaults to action id or generated).
http_request_handler: Handler used to dispatch HTTP requests.
Required: the builder enforces presence at workflow-build time.
"""
super().__init__(action_def, id=id)
self._http_request_handler = http_request_handler
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Execute the HTTP request action."""
state = await self._ensure_state_initialized(ctx, trigger)
method = self._get_method(state)
url = self._get_url(state)
headers = self._get_headers(state)
query_parameters = self._get_query_parameters(state)
body, body_content_type = self._get_body(state)
timeout_ms = self._get_timeout_ms(state)
conversation_id_expr = self._action_def.get("conversationId")
connection_name = self._get_connection_name(state)
info = HttpRequestInfo(
method=method,
url=url,
headers=headers or {},
query_parameters=query_parameters or {},
body=body,
body_content_type=body_content_type,
timeout_ms=timeout_ms,
connection_name=connection_name,
)
try:
result = await self._http_request_handler.send(info)
except (httpx.TimeoutException, TimeoutError) as exc:
raise DeclarativeActionError(f"HTTP request to '{url}' timed out.") from exc
except DeclarativeActionError:
raise
except httpx.HTTPError as exc:
raise DeclarativeActionError(f"HTTP request to '{url}' failed: {type(exc).__name__}") from exc
except Exception as exc:
# Custom HttpRequestHandler implementations may raise arbitrary
# exception types. Wrap them in DeclarativeActionError so workflow
# error handling stays uniform regardless of transport. Note that
# ``asyncio.CancelledError`` is a ``BaseException`` (not
# ``Exception``) and so still propagates unmodified, preserving
# workflow-cancellation semantics.
raise DeclarativeActionError(f"HTTP request to '{url}' failed: {type(exc).__name__}") from exc
if result.is_success_status_code:
self._assign_response(state, result)
self._assign_response_headers(state, result)
self._append_response_to_conversation(state, conversation_id_expr, result.body)
await ctx.send_message(ActionComplete())
return
# Non-success path: still publish headers diagnostically, then raise.
self._assign_response_headers(state, result)
body_preview = _format_body_for_diagnostics(result.body)
if body_preview:
message = f"HTTP request to '{url}' failed with status code {result.status_code}. Body: '{body_preview}'"
else:
message = f"HTTP request to '{url}' failed with status code {result.status_code}."
raise DeclarativeActionError(message)
# ----- Field resolution ----------------------------------------------------
def _get_method(self, state: DeclarativeWorkflowState) -> str:
method = self._action_def.get("method")
evaluated = state.eval_if_expression(method) if method is not None else None
if not evaluated:
return "GET"
return str(evaluated).upper()
def _get_url(self, state: DeclarativeWorkflowState) -> str:
raw = self._action_def.get("url")
if raw is None:
raise ValueError("HttpRequestAction requires a 'url' field.")
evaluated = state.eval_if_expression(raw)
if not isinstance(evaluated, str) or not evaluated:
raise ValueError("HttpRequestAction 'url' evaluated to an empty value.")
return evaluated
def _get_headers(self, state: DeclarativeWorkflowState) -> dict[str, str] | None:
raw_headers = self._action_def.get("headers")
if not isinstance(raw_headers, Mapping) or not raw_headers:
return None
result: dict[str, str] = {}
for key, value in raw_headers.items(): # type: ignore[reportUnknownVariableType]
if not isinstance(key, str) or not key:
continue
evaluated = state.eval_if_expression(value)
if evaluated is None:
continue
text = str(evaluated)
if not text:
continue
result[key] = text
return result or None
def _get_query_parameters(self, state: DeclarativeWorkflowState) -> dict[str, str] | None:
raw_params = self._action_def.get("queryParameters")
if not isinstance(raw_params, Mapping) or not raw_params:
return None
result: dict[str, str] = {}
for key, value in raw_params.items(): # type: ignore[reportUnknownVariableType]
if not isinstance(key, str) or not key or value is None:
continue
evaluated = state.eval_if_expression(value)
formatted = _format_query_value(evaluated)
if formatted is not None:
result[key] = formatted
return result or None
def _get_body(self, state: DeclarativeWorkflowState) -> tuple[str | None, str | None]:
raw_body = self._action_def.get("body")
if raw_body is None:
return None, None
if not isinstance(raw_body, Mapping):
raise ValueError(
"HttpRequestAction 'body' must be a mapping with a 'kind' field (json, raw) or omitted entirely."
)
kind_value: Any = raw_body.get("kind") or raw_body.get("$kind") # type: ignore[reportUnknownMemberType]
if kind_value is None:
raise ValueError(
"HttpRequestAction 'body' is missing 'kind'. Use 'json', 'raw', or omit 'body' for no request body."
)
if not isinstance(kind_value, str):
raise ValueError(f"HttpRequestAction 'body.kind' must be a string, got {kind_value!r}.")
if kind_value in _BODY_KIND_NONE:
return None, None
if kind_value in _BODY_KIND_JSON:
content_expr: Any = raw_body.get("content") # type: ignore[reportUnknownMemberType]
if content_expr is None:
return None, None
evaluated = state.eval_if_expression(content_expr)
try:
body_text = json.dumps(evaluated, default=str)
except (TypeError, ValueError) as exc:
raise ValueError(f"HttpRequestAction 'body.content' could not be serialised as JSON: {exc}") from exc
return body_text, "application/json"
if kind_value in _BODY_KIND_RAW:
content_expr = raw_body.get("content") # type: ignore[reportUnknownMemberType]
content_type_expr: Any = raw_body.get("contentType") # type: ignore[reportUnknownMemberType]
content: str | None = None
if content_expr is not None:
evaluated = state.eval_if_expression(content_expr)
content = None if evaluated is None else str(evaluated)
content_type: str | None = None
if content_type_expr is not None:
ct_eval = state.eval_if_expression(content_type_expr)
ct_text = None if ct_eval is None else str(ct_eval)
content_type = ct_text or None
# Match .NET RawRequestContent semantics: when a raw body is sent
# without an explicit content type, default to text/plain so the
# request is interpretable by servers.
if content is not None and not content_type:
content_type = "text/plain"
return content, content_type
raise ValueError(
f"HttpRequestAction 'body.kind' has unsupported value '{kind_value}'. "
"Expected one of: json, raw, JsonRequestContent, RawRequestContent, "
"NoRequestContent."
)
def _get_timeout_ms(self, state: DeclarativeWorkflowState) -> int | None:
raw = self._action_def.get("requestTimeoutInMilliseconds")
if raw is None:
return None
evaluated = state.eval_if_expression(raw)
if evaluated is None:
return None
try:
value = int(evaluated)
except (TypeError, ValueError):
logger.debug(
"HttpRequestAction: ignoring non-numeric requestTimeoutInMilliseconds=%r",
evaluated,
)
return None
return value if value > 0 else None
def _get_connection_name(self, state: DeclarativeWorkflowState) -> str | None:
connection = self._action_def.get("connection")
if not isinstance(connection, Mapping):
return None
name_expr: Any = connection.get("name") # type: ignore[reportUnknownMemberType]
if name_expr is None:
return None
evaluated = state.eval_if_expression(name_expr)
if evaluated is None:
return None
text = str(evaluated)
return text or None
# ----- Result handling -----------------------------------------------------
def _assign_response(self, state: DeclarativeWorkflowState, result: HttpRequestResult) -> None:
path = _get_path(self._action_def, "response")
if path is None:
return
state.set(path, _parse_response_body(result.body))
def _assign_response_headers(self, state: DeclarativeWorkflowState, result: HttpRequestResult) -> None:
path = _get_path(self._action_def, "responseHeaders")
if path is None:
return
if not result.headers:
state.set(path, None)
return
# Fold multi-value headers with commas (standard HTTP folding) only at
# assignment time. The raw multi-value dict on HttpRequestResult.headers
# is left untouched so callers/tests can inspect duplicates.
flattened: dict[str, str] = {}
for key, values in result.headers.items():
flattened[key] = ",".join(values)
state.set(path, flattened)
def _append_response_to_conversation(
self,
state: DeclarativeWorkflowState,
conversation_id_expr: str | None,
body: str,
) -> None:
if not body:
return
messages_path = _get_messages_path(state, conversation_id_expr)
if messages_path is None:
return
# Mirrors InvokeAzureAgentExecutor: rely on state.append to lazily
# create the conversation entry. Avoids re-parsing the id back out
# of the dotted path string.
message = Message(role="assistant", contents=[body])
state.append(messages_path, message)
HTTP_ACTION_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
"HttpRequestAction": HttpRequestActionExecutor,
}
@@ -0,0 +1,549 @@
# Copyright (c) Microsoft. All rights reserved.
"""Executor for the ``InvokeMcpTool`` declarative action.
Mirrors the .NET ``InvokeMcpToolExecutor``: dispatches an MCP tool call through
the configured :class:`MCPToolHandler`, parses tool outputs, and routes
results to the configured ``output.{result, messages, autoSend}`` paths and
optional conversation history. Supports a human-in-loop approval flow via
``ctx.request_info()`` / :func:`@response_handler` for ``requireApproval=true``.
Security notes:
- Approval requests surface header NAMES only; header values are not echoed,
matching the posture of :mod:`._executors_http`.
- :class:`MCPToolApprovalRequest` carries the values the resume handler will
use; header values are re-evaluated on resume to keep secrets out of
checkpoint state.
- Tool outputs flow back into agent conversations through ``conversationId``
and through Tool-role messages emitted to ``output.messages``. They share
the same prompt-injection risk surface as ``HttpRequestAction``: workflow
authors must trust the MCP server they invoke.
"""
import json
import logging
import uuid
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any
import httpx
from agent_framework import (
Content,
Message,
WorkflowContext,
handler,
response_handler,
)
from agent_framework.exceptions import ToolExecutionException
from ._declarative_base import (
ActionComplete,
DeclarativeActionExecutor,
DeclarativeWorkflowState,
)
from ._executors_tools import ToolApprovalResponse
from ._mcp_handler import MCPToolHandler, MCPToolInvocation, MCPToolResult
__all__ = [
"MCP_ACTION_EXECUTORS",
"InvokeMcpToolActionExecutor",
"MCPToolApprovalRequest",
]
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Request / state types
# ---------------------------------------------------------------------------
@dataclass
class MCPToolApprovalRequest:
"""Approval request emitted before invoking an MCP tool.
Attributes:
request_id: Identifier matching the framework's pending-request key.
tool_name: Evaluated tool name.
server_url: Evaluated MCP server URL.
server_label: Optional human-readable label.
arguments: Evaluated tool arguments.
header_names: Outbound header names (values withheld).
connection_name: Connection identifier the invocation will use.
metadata: Internal routing data pinned at approval-request time
(e.g. ``conversation_id``) for use by the resume handler.
"""
request_id: str
tool_name: str
server_url: str
server_label: str | None
arguments: dict[str, Any]
header_names: list[str] = field(default_factory=lambda: [])
connection_name: str | None = None
metadata: dict[str, Any] = field(default_factory=lambda: {})
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _evaluate_conversation_id(state: DeclarativeWorkflowState, conversation_id_expr: Any) -> str | None:
"""Return the evaluated ``conversationId`` string, or None when empty/unset."""
if not isinstance(conversation_id_expr, str) or not conversation_id_expr:
return None
evaluated = state.eval_if_expression(conversation_id_expr)
if evaluated is None:
return None
text = str(evaluated)
return text or None
def _get_output_path(action_def: Mapping[str, Any], key: str) -> str | None:
"""Extract a state path from ``output.{key}`` field.
Supports two YAML shapes:
- ``output: { result: Local.MyVar }`` — plain string.
- ``output: { result: { path: Local.MyVar } }`` — object form.
"""
output: Any = action_def.get("output")
if not isinstance(output, Mapping):
return None
value: Any = output.get(key) # type: ignore[reportUnknownMemberType]
if isinstance(value, str):
return value or None
if isinstance(value, Mapping):
path: Any = value.get("path") # type: ignore[reportUnknownMemberType]
return path if isinstance(path, str) and path else None
return None
def _format_outputs_for_send(parsed_results: list[Any]) -> str:
"""Render parsed MCP outputs to a string for ``ctx.yield_output(...)``.
- Empty list → ``""``.
- All-string list → newline-joined.
- Single element (any type — scalar, dict, list) → JSON-dumped element.
This avoids surprising ``"[42]"`` / ``"[true]"`` / ``"[null]"`` when
an MCP tool returns a single scalar JSON value.
- Multi-element non-string list → JSON-dump the whole list.
"""
if not parsed_results:
return ""
if all(isinstance(item, str) for item in parsed_results):
return "\n".join(parsed_results)
if len(parsed_results) == 1:
return json.dumps(parsed_results[0], ensure_ascii=False)
return json.dumps(parsed_results, ensure_ascii=False)
# ---------------------------------------------------------------------------
# Executor
# ---------------------------------------------------------------------------
class InvokeMcpToolActionExecutor(DeclarativeActionExecutor):
"""Executor for the ``InvokeMcpTool`` declarative action.
Dispatches through the supplied :class:`MCPToolHandler` and:
- Evaluates ``serverUrl`` / ``toolName`` / ``serverLabel`` / ``arguments``
/ ``headers`` / ``connection.name`` from the action definition.
- When ``requireApproval=true``: emits a :class:`MCPToolApprovalRequest`
via ``ctx.request_info()`` and yields. On resume, the response is
checked; on rejection, ``output.result`` is set to ``"Error: ..."`` and
no tool call is made.
- On success: parses each :class:`agent_framework.Content` output (text →
JSON-first / data / uri → URI string) and assigns the parsed list to
``output.result``. Builds a single Tool-role :class:`Message`
containing all output contents and assigns it to ``output.messages``.
When ``output.autoSend`` is true (default), emits the rendered string
via ``ctx.yield_output(...)``. When ``conversationId`` is configured,
appends an Assistant-role :class:`Message` with the same contents to
``System.conversations.{id}.messages``.
- On error returned by the handler (``is_error=True``): assigns
``"Error: <message>"`` to ``output.result`` and completes normally
(parity with .NET ``AssignErrorAsync``).
.. note::
``output.messages`` receives a SINGLE Tool-role :class:`Message`
(containing the full tool output as ``contents``), unlike
:class:`agent_framework_declarative.InvokeFunctionToolExecutor` which
writes a list of two messages (assistant call + tool result). This
matches the .NET ``InvokeMcpToolExecutor`` output contract.
"""
def __init__(
self,
action_def: dict[str, Any],
*,
id: str | None = None,
mcp_tool_handler: MCPToolHandler,
) -> None:
"""Create an MCP tool action executor.
Args:
action_def: Parsed ``InvokeMcpTool`` YAML dict.
id: Optional executor id (defaults to action id or generated).
mcp_tool_handler: Handler used to dispatch MCP tool calls.
Required: the builder enforces presence at workflow-build
time.
"""
super().__init__(action_def, id=id)
self._mcp_tool_handler = mcp_tool_handler
# ----- Main handler --------------------------------------------------------
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete, str],
) -> None:
"""Execute the MCP tool action."""
state = await self._ensure_state_initialized(ctx, trigger)
server_url = self._get_server_url(state)
tool_name = self._get_tool_name(state)
server_label = self._get_server_label(state)
arguments = self._get_arguments(state)
headers = self._get_headers(state)
connection_name = self._get_connection_name(state)
require_approval = self._get_require_approval(state)
auto_send = self._get_auto_send(state)
conversation_id_expr = self._action_def.get("conversationId")
output_messages_path = _get_output_path(self._action_def, "messages")
output_result_path = _get_output_path(self._action_def, "result")
if require_approval:
request_id = str(uuid.uuid4())
conversation_id = _evaluate_conversation_id(state, conversation_id_expr)
request = MCPToolApprovalRequest(
request_id=request_id,
tool_name=tool_name,
server_url=server_url,
server_label=server_label,
arguments=arguments,
header_names=sorted(headers.keys()),
connection_name=connection_name,
metadata={"conversation_id": conversation_id},
)
logger.info(
"%s: requesting approval for MCP tool '%s' on '%s'",
self.__class__.__name__,
tool_name,
server_url,
)
await ctx.request_info(request, ToolApprovalResponse, request_id=request_id)
return
# No approval required - invoke directly.
invocation = MCPToolInvocation(
server_url=server_url,
tool_name=tool_name,
server_label=server_label,
arguments=arguments,
headers=headers,
connection_name=connection_name,
)
result = await self._invoke_with_narrow_catch(invocation)
await self._process_result(
ctx=ctx,
state=state,
result=result,
auto_send=auto_send,
conversation_id=_evaluate_conversation_id(state, conversation_id_expr),
output_messages_path=output_messages_path,
output_result_path=output_result_path,
)
await ctx.send_message(ActionComplete())
# ----- Approval response handler ------------------------------------------
@response_handler
async def handle_approval_response(
self,
original_request: MCPToolApprovalRequest,
response: ToolApprovalResponse,
ctx: WorkflowContext[ActionComplete, str],
) -> None:
"""Resume the invocation using the values pinned on ``original_request``."""
state = self._get_state(ctx.state)
tool_name = original_request.tool_name
metadata: dict[str, Any] = getattr(original_request, "metadata", None) or {}
raw_conversation_id = metadata.get("conversation_id")
conversation_id = raw_conversation_id if isinstance(raw_conversation_id, str) and raw_conversation_id else None
auto_send = self._get_auto_send(state)
output_messages_path = _get_output_path(self._action_def, "messages")
output_result_path = _get_output_path(self._action_def, "result")
if not response.approved:
logger.info(
"%s: MCP tool '%s' rejected: %s",
self.__class__.__name__,
tool_name,
response.reason,
)
self._assign_error(state, output_result_path, "MCP tool invocation was not approved by user.")
await ctx.send_message(ActionComplete())
return
invocation = MCPToolInvocation(
server_url=original_request.server_url,
tool_name=tool_name,
server_label=original_request.server_label,
arguments=original_request.arguments,
headers=self._evaluate_headers(state, self._action_def.get("headers")),
connection_name=getattr(original_request, "connection_name", None),
)
result = await self._invoke_with_narrow_catch(invocation)
await self._process_result(
ctx=ctx,
state=state,
result=result,
auto_send=auto_send,
conversation_id=conversation_id,
output_messages_path=output_messages_path,
output_result_path=output_result_path,
)
await ctx.send_message(ActionComplete())
# ----- Field resolution ----------------------------------------------------
def _get_server_url(self, state: DeclarativeWorkflowState) -> str:
raw = self._action_def.get("serverUrl")
if raw is None:
raise ValueError("InvokeMcpTool requires a 'serverUrl' field.")
evaluated = state.eval_if_expression(raw)
if not isinstance(evaluated, str) or not evaluated:
raise ValueError("InvokeMcpTool 'serverUrl' evaluated to an empty value.")
return evaluated
def _get_tool_name(self, state: DeclarativeWorkflowState) -> str:
raw = self._action_def.get("toolName")
if raw is None:
raise ValueError("InvokeMcpTool requires a 'toolName' field.")
evaluated = state.eval_if_expression(raw)
if not isinstance(evaluated, str) or not evaluated:
raise ValueError("InvokeMcpTool 'toolName' evaluated to an empty value.")
return evaluated
def _get_server_label(self, state: DeclarativeWorkflowState) -> str | None:
raw = self._action_def.get("serverLabel")
if raw is None:
return None
evaluated = state.eval_if_expression(raw)
if evaluated is None:
return None
text = str(evaluated)
return text or None
def _get_arguments(self, state: DeclarativeWorkflowState) -> dict[str, Any]:
"""Evaluate ``arguments`` map. Preserves ``None`` values (parity with .NET)."""
raw = self._action_def.get("arguments")
if raw is None:
return {}
if not isinstance(raw, Mapping) or not raw:
return {}
result: dict[str, Any] = {}
for key, value in raw.items(): # type: ignore[reportUnknownVariableType]
if not isinstance(key, str) or not key:
continue
result[key] = state.eval_if_expression(value)
return result
def _get_headers(self, state: DeclarativeWorkflowState) -> dict[str, str]:
return self._evaluate_headers(state, self._action_def.get("headers"))
@staticmethod
def _evaluate_headers(state: DeclarativeWorkflowState, headers_def: Any) -> dict[str, str]:
"""Evaluate the ``headers`` map. Empty string values are skipped."""
if not isinstance(headers_def, Mapping) or not headers_def:
return {}
result: dict[str, str] = {}
for key, value in headers_def.items(): # type: ignore[reportUnknownVariableType]
if not isinstance(key, str) or not key:
continue
evaluated = state.eval_if_expression(value)
if evaluated is None:
continue
text = str(evaluated)
if not text:
continue
result[key] = text
return result
def _get_connection_name(self, state: DeclarativeWorkflowState) -> str | None:
connection = self._action_def.get("connection")
if not isinstance(connection, Mapping):
return None
name_expr: Any = connection.get("name") # type: ignore[reportUnknownMemberType]
if name_expr is None:
return None
evaluated = state.eval_if_expression(name_expr)
if evaluated is None:
return None
text = str(evaluated)
return text or None
def _get_require_approval(self, state: DeclarativeWorkflowState) -> bool:
raw = self._action_def.get("requireApproval")
if raw is None:
return False
evaluated = state.eval_if_expression(raw)
if isinstance(evaluated, bool):
return evaluated
if isinstance(evaluated, str):
return evaluated.strip().lower() in {"true", "1", "yes"}
return bool(evaluated)
def _get_auto_send(self, state: DeclarativeWorkflowState) -> bool:
output: Any = self._action_def.get("output")
if not isinstance(output, Mapping):
return True
raw: Any = output.get("autoSend") # type: ignore[reportUnknownMemberType]
if raw is None:
return True
evaluated = state.eval_if_expression(raw)
if isinstance(evaluated, bool):
return evaluated
if isinstance(evaluated, str):
return evaluated.strip().lower() in {"true", "1", "yes"}
return bool(evaluated)
# ----- Invocation + error handling ----------------------------------------
async def _invoke_with_narrow_catch(self, invocation: MCPToolInvocation) -> MCPToolResult:
"""Invoke the handler with a narrow exception catch.
Only known transport / tool exceptions are normalised to an error
result. Programmer bugs (TypeError, ValueError from misuse, etc.)
propagate so they fail loudly.
``asyncio.CancelledError`` is a ``BaseException``, not ``Exception``,
so it is not caught here and propagates unchanged for workflow
cancellation.
"""
try:
return await self._mcp_tool_handler.invoke_tool(invocation)
except ToolExecutionException as exc:
message = str(exc) or type(exc).__name__
return MCPToolResult(
outputs=[Content.from_text(f"Error: {message}")],
is_error=True,
error_message=message,
)
except httpx.HTTPError as exc:
message = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
return MCPToolResult(
outputs=[Content.from_text(f"Error: {message}")],
is_error=True,
error_message=message,
)
except Exception as exc:
try:
from mcp.shared.exceptions import McpError
except ImportError: # pragma: no cover - mcp is a hard dep
raise
if isinstance(exc, McpError):
message = str(exc) or type(exc).__name__
return MCPToolResult(
outputs=[Content.from_text(f"Error: {message}")],
is_error=True,
error_message=message,
)
raise
# ----- Result handling -----------------------------------------------------
async def _process_result(
self,
*,
ctx: WorkflowContext[ActionComplete, str],
state: DeclarativeWorkflowState,
result: MCPToolResult,
auto_send: bool,
conversation_id: str | None,
output_messages_path: str | None,
output_result_path: str | None,
) -> None:
"""Apply ``result`` to workflow state per the configured output paths."""
if result.is_error:
# Error path mirrors .NET ``AssignErrorAsync`` — only the result
# path is touched; messages / autoSend / conversation are not.
self._assign_error(
state,
output_result_path,
result.error_message or "MCP tool invocation failed.",
)
return
parsed_results = _parse_outputs(result.outputs)
if output_result_path is not None and parsed_results:
state.set(output_result_path, parsed_results)
# Single Tool-role message (matches .NET line 178 contract). Differs
# from InvokeFunctionTool's two-message [assistant call, tool result]
# convention.
tool_message = Message(role="tool", contents=list(result.outputs))
if output_messages_path is not None:
state.set(output_messages_path, tool_message)
if auto_send and parsed_results:
await ctx.yield_output(_format_outputs_for_send(parsed_results))
if conversation_id:
messages_path = f"System.conversations.{conversation_id}.messages"
assistant_message = Message(role="assistant", contents=list(result.outputs))
state.append(messages_path, assistant_message)
@staticmethod
def _assign_error(
state: DeclarativeWorkflowState,
output_result_path: str | None,
error_message: str,
) -> None:
"""Mirror .NET ``AssignErrorAsync``: store ``"Error: <msg>"`` at the result path."""
if output_result_path is None:
return
state.set(output_result_path, f"Error: {error_message}")
def _parse_outputs(outputs: list[Content]) -> list[Any]:
"""Parse :class:`Content` outputs into Python values for ``output.result``.
Mirrors .NET ``AssignResultAsync``:
- ``TextContent`` → JSON-parse text; on failure use the raw text.
- ``DataContent`` / ``UriContent`` → ``content.uri``.
- Other content kinds → ``str(content)``.
"""
parsed: list[Any] = []
for content in outputs:
kind = getattr(content, "type", None)
if kind == "text":
text_value = getattr(content, "text", None)
text_str = "" if text_value is None else str(text_value)
try:
parsed.append(json.loads(text_str))
except (json.JSONDecodeError, ValueError):
parsed.append(text_str)
continue
if kind in ("data", "uri"):
uri_value = getattr(content, "uri", None)
parsed.append("" if uri_value is None else str(uri_value))
continue
parsed.append(str(content))
return parsed
MCP_ACTION_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
"InvokeMcpTool": InvokeMcpToolActionExecutor,
}
@@ -0,0 +1,660 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tool invocation executors for declarative workflows.
Provides base abstractions and concrete executors for invoking various tool types
(functions, APIs, MCP servers, etc.) with support for approval flows and structured output.
This module is designed for extensibility:
- BaseToolExecutor provides common patterns (registry lookup, approval flow, output formatting)
- Concrete executors (InvokeFunctionToolExecutor) implement tool-specific invocation logic
- New tool types can be added by subclassing BaseToolExecutor
"""
import json
import logging
import uuid
from abc import abstractmethod
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from inspect import isawaitable
from typing import Any, cast
from agent_framework import (
Content,
Message,
WorkflowContext,
handler,
response_handler,
)
from ._declarative_base import (
ActionComplete,
DeclarativeActionExecutor,
DeclarativeWorkflowState,
)
from ._executors_agents import TOOL_REGISTRY_KEY
logger = logging.getLogger(__name__)
# Registry key for function tools in State - reuse existing key so functions registered
# at runtime are discoverable by both agent-based and function-based tool executors.
FUNCTION_TOOL_REGISTRY_KEY = TOOL_REGISTRY_KEY
# ============================================================================
# Request/Response Types for Approval Flow
# ============================================================================
@dataclass
class ToolApprovalRequest:
"""Request for approval before invoking a tool.
Emitted when requireApproval=true, signaling that the workflow should yield
and wait for user approval before invoking the tool.
This follows the same pattern as AgentExternalInputRequest from _executors_agents.py,
allowing consistent handling of human-in-loop scenarios across agents and tools.
Attributes:
request_id: Unique identifier for this approval request.
function_name: Evaluated function name to be invoked.
arguments: Evaluated arguments to be passed to the function.
"""
request_id: str
function_name: str
arguments: dict[str, Any]
@dataclass
class ToolApprovalResponse:
"""Response to a ToolApprovalRequest.
Provided by the caller to approve or reject tool invocation.
Attributes:
approved: Whether the tool invocation was approved.
reason: Optional reason for rejection.
"""
approved: bool
reason: str | None = None
# ============================================================================
# Result Types
# ============================================================================
@dataclass
class ToolInvocationResult:
"""Result from a tool invocation.
Attributes:
success: Whether the invocation succeeded.
result: The return value from the tool (if successful).
error: Error message (if failed).
messages: Message list format for conversation history.
rejected: Whether the invocation was rejected during approval.
rejection_reason: Reason for rejection.
"""
success: bool
result: Any = None
error: str | None = None
messages: list[Message] = field(default_factory=cast(Callable[..., list[Message]], list))
rejected: bool = False
rejection_reason: str | None = None
# ============================================================================
# Helper Functions
# ============================================================================
def _normalize_variable_path(variable: str) -> str:
"""Normalize variable names to ensure they have a scope prefix.
Args:
variable: Variable name like 'Local.X' or 'weatherResult'
Returns:
The variable path with a scope prefix (defaults to Local if none provided)
"""
if variable.startswith(("Local.", "System.", "Workflow.", "Agent.", "Conversation.")):
return variable
if "." in variable:
return variable
return "Local." + variable
# ============================================================================
# Base Tool Executor (Abstract)
# ============================================================================
class BaseToolExecutor(DeclarativeActionExecutor):
"""Base class for tool invocation executors.
Provides common functionality for all tool-like executors:
- Tool registry lookup (State + WorkflowFactory registration)
- Approval flow (request_info pattern with yield/resume)
- Output formatting (messages as Message list + result variable)
- Error handling (stores error in output, doesn't raise)
Subclasses must implement:
- _invoke_tool(): Perform the actual tool invocation
YAML Schema (common fields):
kind: <ToolKind>
id: unique_id
functionName: function_to_call # required, supports =expression syntax
requireApproval: true # optional, default=false
arguments: # optional dictionary
param1: value1
param2: =Local.dynamicValue
output:
messages: Local.toolCallMessages # Message list
result: Local.toolResult
autoSend: true # optional, default=true
"""
def __init__(
self,
action_def: dict[str, Any],
*,
id: str | None = None,
tools: dict[str, Any] | None = None,
):
"""Initialize the tool executor.
Args:
action_def: The action definition from YAML
id: Optional executor ID
tools: Registry of tool instances by name (from WorkflowFactory)
"""
super().__init__(action_def, id=id)
self._tools = tools or {}
@abstractmethod
async def _invoke_tool(
self,
tool: Any,
function_name: str,
arguments: dict[str, Any],
state: DeclarativeWorkflowState,
) -> Any:
"""Invoke the tool with the given arguments.
Args:
tool: The tool instance to invoke
function_name: Function/method name to call
arguments: Arguments to pass
state: Workflow state
Returns:
The result from the tool invocation
Raises:
Any exception from the tool invocation
"""
pass
def _get_tool(
self,
function_name: str,
ctx: WorkflowContext[Any, Any],
) -> Any | None:
"""Get tool from registry.
Checks both WorkflowFactory registry (self._tools) and State registry.
Args:
function_name: Name of the function
ctx: Workflow context
Returns:
The tool/function, or None if not found
"""
# Check WorkflowFactory registry first (passed in constructor)
tool = self._tools.get(function_name)
if tool is not None:
return tool
# Check State registry (for runtime registration)
try:
tool_registry: dict[str, Any] | None = ctx.state.get(FUNCTION_TOOL_REGISTRY_KEY)
if tool_registry:
return tool_registry.get(function_name)
except KeyError:
logger.debug(
"%s: tool registry key '%s' not found in state "
"(this is normal if tools are only registered via WorkflowFactory)",
self.__class__.__name__,
FUNCTION_TOOL_REGISTRY_KEY,
)
return None
def _get_output_config(self) -> tuple[str | None, str | None, bool]:
"""Parse output configuration from action definition.
Returns:
Tuple of (messages_var, result_var, auto_send)
"""
output_config: dict[str, str | bool] = self._action_def.get("output", {})
if not isinstance(output_config, Mapping):
return None, None, True
messages_var = output_config.get("messages")
result_var = output_config.get("result")
auto_send = bool(output_config.get("autoSend", True))
return (
str(messages_var) if messages_var else None,
str(result_var) if result_var else None,
auto_send,
)
def _store_result(
self,
result: ToolInvocationResult,
state: DeclarativeWorkflowState,
messages_var: str | None,
result_var: str | None,
) -> None:
"""Store tool invocation result in workflow state.
Args:
result: The tool invocation result
state: Workflow state
messages_var: Variable path for messages output
result_var: Variable path for result output
"""
# Store messages if variable specified
if messages_var:
path = _normalize_variable_path(messages_var)
state.set(path, result.messages)
# Store result if variable specified
if result_var:
path = _normalize_variable_path(result_var)
if result.rejected:
state.set(
path,
{
"approved": False,
"rejected": True,
"reason": result.rejection_reason,
},
)
elif result.success:
state.set(path, result.result)
else:
state.set(
path,
{
"error": result.error,
},
)
async def _format_messages(
self,
function_name: str,
arguments: dict[str, Any],
result: Any,
) -> list[Message]:
"""Format tool invocation as Message list.
Creates tool call + tool result message pair for conversation history,
following the same format as agent tool calls.
Args:
function_name: Function name invoked
arguments: Arguments passed
result: Result from invocation
Returns:
List of Message objects [tool_call_message, tool_result_message]
"""
call_id = str(uuid.uuid4())
# Safely serialize arguments to JSON
try:
arguments_str = json.dumps(arguments) if isinstance(arguments, dict) else str(arguments)
except (TypeError, ValueError) as e:
logger.warning(f"Failed to serialize arguments to JSON: {e}")
arguments_str = str(arguments)
# Tool call message (from assistant)
tool_call_content = Content.from_function_call(
call_id=call_id,
name=function_name,
arguments=arguments_str,
)
tool_call_message = Message(
role="assistant",
contents=[tool_call_content],
)
# Safely serialize result to JSON
try:
result_str = json.dumps(result) if not isinstance(result, str) else result
except (TypeError, ValueError) as e:
logger.warning(f"Failed to serialize result to JSON: {e}")
result_str = str(result)
tool_result_content = Content.from_function_result(
call_id=call_id,
result=result_str,
)
tool_result_message = Message(
role="tool",
contents=[tool_result_content],
)
return [tool_call_message, tool_result_message]
async def _execute_tool_invocation(
self,
function_name: str,
arguments: dict[str, Any],
state: DeclarativeWorkflowState,
ctx: WorkflowContext[Any, Any],
) -> ToolInvocationResult:
"""Execute the tool invocation.
Args:
function_name: Function to invoke
arguments: Arguments to pass
state: Workflow state
ctx: Workflow context
Returns:
ToolInvocationResult with outcome
"""
# Get tool from registry
tool = self._get_tool(function_name, ctx)
if tool is None:
error_msg = f"Function '{function_name}' not found in registry"
logger.error(f"{self.__class__.__name__}: {error_msg}")
return ToolInvocationResult(
success=False,
error=error_msg,
)
try:
# Invoke the tool (subclass implements this)
result_value = await self._invoke_tool(
tool=tool,
function_name=function_name,
arguments=arguments,
state=state,
)
# Format as messages for conversation history
messages = await self._format_messages(
function_name=function_name,
arguments=arguments,
result=result_value,
)
return ToolInvocationResult(
success=True,
result=result_value,
messages=messages,
)
except Exception as e:
logger.error(
"%s: error invoking function '%s': %s: %s",
self.__class__.__name__,
function_name,
type(e).__name__,
e,
exc_info=True,
)
return ToolInvocationResult(
success=False,
error=f"{type(e).__name__}: {e}",
)
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete, str],
) -> None:
"""Handle the tool invocation with optional approval flow.
When requireApproval=true:
1. Saves invocation state to State (keyed by executor ID)
2. Emits ToolApprovalRequest via ctx.request_info()
3. Workflow yields (returns without ActionComplete)
4. Resumes in handle_approval_response() when user responds
"""
state = await self._ensure_state_initialized(ctx, trigger)
# Parse output configuration early so we can store errors
messages_var, result_var, auto_send = self._get_output_config()
# Get and evaluate function name (required)
function_name_expr = self._action_def.get("functionName")
if not function_name_expr:
error_msg = f"Action '{self.id}' is missing required 'functionName' field"
logger.error(f"{self.__class__.__name__}: {error_msg}")
if result_var:
state.set(_normalize_variable_path(result_var), {"error": error_msg})
await ctx.send_message(ActionComplete())
return
function_name = state.eval_if_expression(function_name_expr)
if not function_name:
error_msg = f"Action '{self.id}': functionName expression evaluated to empty"
logger.error(f"{self.__class__.__name__}: {error_msg}")
if result_var:
state.set(_normalize_variable_path(result_var), {"error": error_msg})
await ctx.send_message(ActionComplete())
return
function_name = str(function_name)
# Evaluate arguments
arguments_def = self._action_def.get("arguments", {})
arguments: dict[str, Any] = {}
if arguments_def is not None and not isinstance(arguments_def, dict):
logger.warning(
"%s: 'arguments' must be a dictionary, got %s - ignoring",
self.__class__.__name__,
type(arguments_def).__name__,
)
elif isinstance(arguments_def, dict):
for key, value in arguments_def.items(): # type: ignore[reportUnknownVariableType]
arguments[key] = state.eval_if_expression(value)
# Check if approval is required
require_approval = self._action_def.get("requireApproval", False)
if require_approval:
# Emit approval request - the request payload is the source of
# truth for resumed invocation; no side-channel state is written.
request_id = str(uuid.uuid4())
request = ToolApprovalRequest(
request_id=request_id,
function_name=function_name,
arguments=arguments,
)
logger.info(f"{self.__class__.__name__}: requesting approval for '{function_name}'")
await ctx.request_info(request, ToolApprovalResponse, request_id=request_id)
# Workflow yields - will resume in handle_approval_response
return
# No approval required - invoke directly
result = await self._execute_tool_invocation(
function_name=function_name,
arguments=arguments,
state=state,
ctx=ctx,
)
self._store_result(result, state, messages_var, result_var)
if auto_send and result.success and result.result is not None:
await ctx.yield_output(str(result.result))
await ctx.send_message(ActionComplete())
@response_handler
async def handle_approval_response(
self,
original_request: ToolApprovalRequest,
response: ToolApprovalResponse,
ctx: WorkflowContext[ActionComplete, str],
) -> None:
"""Handle response to a ToolApprovalRequest.
Resumes after the workflow yielded for approval. The invocation
``function_name`` and ``arguments`` are sourced from
``original_request`` (the payload the reviewer approved); output
configuration is re-derived from the executor's action definition.
"""
state = self._get_state(ctx.state)
function_name = original_request.function_name
arguments = original_request.arguments
messages_var, result_var, auto_send = self._get_output_config()
# Check if approved
if not response.approved:
logger.info(f"{self.__class__.__name__}: tool invocation rejected: {response.reason}")
# Store rejection status (don't raise error)
result = ToolInvocationResult(
success=False,
rejected=True,
rejection_reason=response.reason,
messages=[
Message(
role="assistant",
contents=[
f"Function '{function_name}' was rejected: {response.reason or 'No reason provided'}"
],
)
],
)
self._store_result(result, state, messages_var, result_var)
await ctx.send_message(ActionComplete())
return
# Approved - execute the invocation
result = await self._execute_tool_invocation(
function_name=function_name,
arguments=arguments,
state=state,
ctx=ctx,
)
self._store_result(result, state, messages_var, result_var)
if auto_send and result.success and result.result is not None:
await ctx.yield_output(str(result.result))
await ctx.send_message(ActionComplete())
# ============================================================================
# Function Tool Executor (Concrete)
# ============================================================================
class InvokeFunctionToolExecutor(BaseToolExecutor):
"""Executor that invokes a Python function as a tool.
This executor supports invoking registered Python functions with:
- Expression evaluation for functionName and arguments
- Optional approval flow (yield/resume pattern)
- Async function support
- Message list output for conversation history
YAML Schema:
kind: InvokeFunctionTool
id: invoke_function_example
functionName: get_weather # required, supports =expression syntax
requireApproval: true # optional, default=false
arguments: # optional dictionary
location: =Local.location
unit: F
output:
messages: Local.weatherToolCallItems # Message list
result: Local.WeatherInfo
autoSend: true # optional, default=true
Tool Registration:
Tools can be registered via:
1. WorkflowFactory.register_tool("name", func) - preferred
2. Setting FUNCTION_TOOL_REGISTRY_KEY in State at runtime
Examples:
.. code-block:: python
from agent_framework_declarative import WorkflowFactory
def get_weather(location: str, unit: str = "F") -> dict:
return {"temp": 72, "unit": unit, "location": location}
async def fetch_data(url: str) -> dict:
# async function example
return {"data": "..."}
factory = (
WorkflowFactory().register_tool("get_weather", get_weather).register_tool("fetch_data", fetch_data)
)
workflow = factory.create_workflow_from_yaml_path("workflow.yaml")
"""
async def _invoke_tool(
self,
tool: Any,
function_name: str,
arguments: dict[str, Any],
state: DeclarativeWorkflowState,
) -> Any:
"""Invoke the function tool.
Supports:
- Direct callable functions
- Async functions (via inspect.isawaitable)
Args:
tool: The tool/function to invoke
function_name: Name of the function (for error messages)
arguments: Arguments to pass to the function
state: Workflow state (not used for function tools)
Returns:
The result from the function invocation
Raises:
ValueError: If the tool is not callable
"""
if not callable(tool):
raise ValueError(f"Function '{function_name}' is not callable")
# Invoke the function
result = tool(**arguments)
# Handle async functions
if isawaitable(result):
result = await result
return result
# ============================================================================
# Executor Registry Export
# ============================================================================
TOOL_ACTION_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
"InvokeFunctionTool": InvokeFunctionToolExecutor,
}
@@ -0,0 +1,808 @@
# 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 __future__ import annotations
import logging
from collections.abc import Mapping
from pathlib import Path
from typing import Any, cast
import yaml
from agent_framework import (
AgentExecutor,
CheckpointStorage,
SupportsAgentRun,
Workflow,
)
from .._loader import AgentFactory
from ._declarative_base import DeclarativeEnvConfig, discover_env_references
from ._declarative_builder import DeclarativeWorkflowBuilder
from ._errors import DeclarativeWorkflowError
from ._http_handler import HttpRequestHandler
from ._mcp_handler import MCPToolHandler
logger = logging.getLogger("agent_framework.declarative")
__all__ = ["WorkflowFactory"]
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({"query": "Hello"}, stream=True):
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.openai import OpenAIChatClient
from agent_framework.declarative import WorkflowFactory
# Pre-register agents for InvokeAzureAgent actions
client = OpenAIChatClient()
agent = client.as_agent(name="MyAgent", instructions="You are helpful.")
factory = WorkflowFactory(agents={"MyAgent": agent})
workflow = factory.create_workflow_from_yaml_path("workflow.yaml")
"""
_agents: dict[str, SupportsAgentRun | AgentExecutor]
def __init__(
self,
*,
agent_factory: AgentFactory | None = None,
agents: Mapping[str, SupportsAgentRun | AgentExecutor] | None = None,
bindings: Mapping[str, Any] | None = None,
env_file: str | None = None,
checkpoint_storage: CheckpointStorage | None = None,
max_iterations: int | None = None,
http_request_handler: HttpRequestHandler | None = None,
mcp_tool_handler: MCPToolHandler | None = None,
configuration: Mapping[str, str] | None = None,
restrict_env_to_configuration: bool = True,
) -> 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.
max_iterations: Optional maximum runner supersteps. Overrides the YAML ``maxTurns``
field and the core default (100). Workflows with ``GotoAction`` loops (e.g.
DeepResearch) typically need a higher value.
http_request_handler: Optional handler used to dispatch HTTP requests for
``HttpRequestAction``. Required if the workflow contains any
``HttpRequestAction``; build will fail with :class:`DeclarativeWorkflowError`
otherwise. Use :class:`agent_framework.declarative.DefaultHttpRequestHandler`
for a no-policy ``httpx``-based default, or supply your own implementation
to enforce SSRF guards, allowlisting, or auth resolution.
mcp_tool_handler: Optional handler used to dispatch MCP tool calls for
``InvokeMcpTool``. Required if the workflow contains any
``InvokeMcpTool``; build will fail with :class:`DeclarativeWorkflowError`
otherwise. Use :class:`agent_framework.declarative.DefaultMCPToolHandler`
for a default backed by :class:`agent_framework.MCPStreamableHTTPTool`,
or supply your own implementation to enforce SSRF guards, allowlisting,
or auth/connection resolution.
configuration: Optional mapping that populates the PowerFx ``Env``
symbol referenced from workflow YAML expressions (e.g.
``=Env.MY_KEY``). Keys supplied here are always exposed
under ``Env.<key>``; the process ``os.environ`` is consulted
only when ``restrict_env_to_configuration`` is ``False``.
When neither source produces a value the ``Env`` symbol is
omitted so ``=Env.X`` evaluates to the literal expression
string.
restrict_env_to_configuration: When ``True`` (default), the
``Env`` PowerFx symbol is populated exclusively from
``configuration``; ``os.environ`` is never consulted. Set to
``False`` to additionally fall back to ``os.environ`` for
names absent from ``configuration`` that the workflow YAML
explicitly references. The fallback is constrained to names
discovered in PowerFx expressions inside the workflow
definition so unrelated environment variables never enter
the PowerFx scope.
Examples:
.. code-block:: python
from agent_framework.declarative import WorkflowFactory
# Minimal initialization
factory = WorkflowFactory()
.. code-block:: python
from agent_framework.openai import OpenAIChatClient
from agent_framework.declarative import WorkflowFactory
# With pre-registered agents
client = OpenAIChatClient()
agents = {
"WriterAgent": client.as_agent(name="Writer", instructions="Write content."),
"ReviewerAgent": client.as_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",
)
.. code-block:: python
from agent_framework.declarative import WorkflowFactory
# Inject named values for =Env.* references in the workflow YAML
factory = WorkflowFactory(
configuration={
"MY_SERVER_URL": "https://example.com",
"MY_TOOL_NAME": "search",
},
)
"""
self._agent_factory = agent_factory or AgentFactory(env_file_path=env_file)
self._agents: dict[str, SupportsAgentRun | AgentExecutor] = dict(agents) if agents else {}
self._bindings: dict[str, Any] = dict(bindings) if bindings else {}
self._tools: dict[str, Any] = {} # Tool registry for InvokeFunctionTool actions
self._checkpoint_storage = checkpoint_storage
self._max_iterations = max_iterations
self._http_request_handler = http_request_handler
self._mcp_tool_handler = mcp_tool_handler
self._configuration: dict[str, str] = dict(configuration) if configuration else {}
self._restrict_env_to_configuration = restrict_env_to_configuration
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({"input": "Hello"}, stream=True):
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, SupportsAgentRun | 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, SupportsAgentRun | 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 DeclarativeEnvConfig from the factory's configuration and the
# set of Env references actually used in the workflow PowerFx expressions.
# The referenced-name allowlist constrains ``os.environ`` fallback (when
# enabled) so unrelated variables never enter the PowerFx scope.
env_config = DeclarativeEnvConfig(
values=dict(self._configuration),
restrict_to_configuration=self._restrict_env_to_configuration,
referenced_names=frozenset(discover_env_references(normalized_def)),
)
# Build the graph-based workflow, passing agents and tools for specialized executors
try:
graph_builder = DeclarativeWorkflowBuilder(
normalized_def,
workflow_id=name,
agents=agents,
tools=self._tools,
checkpoint_storage=self._checkpoint_storage,
max_iterations=self._max_iterations,
http_request_handler=self._http_request_handler,
mcp_tool_handler=self._mcp_tool_handler,
env_config=env_config,
)
workflow = graph_builder.build()
except ValueError as e:
raise DeclarativeWorkflowError(f"Failed to build graph-based workflow: {e}") from e
# Store agents, bindings, and tools for reference (executors already have them)
workflow._declarative_agents = agents # type: ignore[attr-defined]
workflow._declarative_bindings = self._bindings # type: ignore[attr-defined]
workflow._declarative_tools = self._tools # 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: SupportsAgentRun | 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 Agent or similar).
Returns:
Self for method chaining.
Examples:
.. code-block:: python
from agent_framework.openai import OpenAIChatClient
from agent_framework.declarative import WorkflowFactory
client = OpenAIChatClient()
# Method chaining to register multiple agents
factory = (
WorkflowFactory()
.register_agent(
"Writer",
client.as_agent(
name="Writer",
instructions="Write content.",
),
)
.register_agent(
"Reviewer",
client.as_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")
"""
if not callable(func):
raise TypeError(f"Expected a callable for binding '{name}', got {type(func).__name__}")
self._bindings[name] = func
return self
def register_tool(self, name: str, func: Any) -> WorkflowFactory:
"""Register a function with the factory for use in InvokeFunctionTool actions.
Registered functions are available to InvokeFunctionTool actions by name via the functionName field.
This method supports fluent chaining.
Args:
name: The name to register the function under. Must match the functionName
referenced in InvokeFunctionTool actions.
func: The function to register (can be sync or async).
Returns:
Self for method chaining.
Examples:
.. code-block:: python
from agent_framework_declarative import WorkflowFactory
def get_weather(location: str, unit: str = "F") -> dict:
return {"temp": 72, "unit": unit, "location": location}
async def fetch_data(url: str) -> dict:
# Async function example
return {"data": "..."}
# Register functions for use in InvokeFunctionTool workflow actions
factory = (
WorkflowFactory().register_tool("get_weather", get_weather).register_tool("fetch_data", fetch_data)
)
workflow = factory.create_workflow_from_yaml_path("workflow.yaml")
The workflow YAML can then reference these tools:
.. code-block:: yaml
actions:
- kind: InvokeFunctionTool
id: call_weather
functionName: get_weather
arguments:
location: =Local.city
unit: F
output:
result: Local.weatherData
"""
if not callable(func):
raise TypeError(f"Expected a callable for tool '{name}', got {type(func).__name__}")
self._tools[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
@@ -0,0 +1,237 @@
# Copyright (c) Microsoft. All rights reserved.
"""HTTP request handler abstraction for declarative workflows.
Mirrors the .NET ``IHttpRequestHandler`` / ``DefaultHttpRequestHandler`` pair from
``Microsoft.Agents.AI.Workflows.Declarative``. Provides:
- :class:`HttpRequestInfo` — request input data passed from the executor.
- :class:`HttpRequestResult` — response data returned to the executor.
- :class:`HttpRequestHandler` — :class:`typing.Protocol` callers implement to plug
in custom transports (e.g. with allowlisting, mTLS, retries, etc.).
- :class:`DefaultHttpRequestHandler` — production-grade default backed by
``httpx.AsyncClient``.
Security note: :class:`DefaultHttpRequestHandler` performs **no** URL filtering
or SSRF protection. Production deployments should supply a custom handler that
enforces an allowlist or DNS-rebinding-resistant policy. This split mirrors the
.NET design.
"""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass, field
from typing import Any, Protocol, runtime_checkable
import httpx
__all__ = [
"DefaultHttpRequestHandler",
"HttpRequestHandler",
"HttpRequestInfo",
"HttpRequestResult",
]
@dataclass
class HttpRequestInfo:
"""Description of an HTTP request to be dispatched by a :class:`HttpRequestHandler`.
Mirrors the .NET ``HttpRequestInfo`` record. Field semantics:
- ``method``: HTTP method (``GET``, ``POST``, etc.). Already upper-cased by the executor.
- ``url``: Absolute URL. Already evaluated from the YAML expression.
- ``headers``: Single-value header map (case-insensitive keys per HTTP semantics
but stored as authored). Empty values are skipped by the executor.
- ``query_parameters``: String key/value pairs appended to the URL.
- ``body``: Request body bytes/text, or ``None`` for no body.
- ``body_content_type``: Content type to send (e.g. ``application/json``).
Ignored when ``body`` is ``None``.
- ``timeout_ms``: Per-request timeout in milliseconds. ``None`` => use the
handler's default.
- ``connection_name``: Optional Foundry connection name for handlers that
resolve auth/credentials by connection.
"""
method: str
url: str
headers: dict[str, str] = field(default_factory=dict) # type: ignore[reportUnknownVariableType]
query_parameters: dict[str, str] = field(default_factory=dict) # type: ignore[reportUnknownVariableType]
body: str | None = None
body_content_type: str | None = None
timeout_ms: int | None = None
connection_name: str | None = None
@dataclass
class HttpRequestResult:
"""Response returned by a :class:`HttpRequestHandler`.
Mirrors the .NET ``HttpRequestResult`` record. ``headers`` preserves
multi-value response headers (e.g. multiple ``Set-Cookie`` headers) as a
``dict[str, list[str]]``. The executor folds duplicates into a single
comma-joined string only at the point it assigns ``responseHeaders`` to
workflow state.
Header keys are normalized to lowercase so that lookups are consistent
regardless of the server's transmitted casing (HTTP headers are
case-insensitive per RFC 7230 §3.2). Custom :class:`HttpRequestHandler`
implementations should follow the same convention.
"""
status_code: int
is_success_status_code: bool
body: str
headers: dict[str, list[str]] = field(default_factory=dict) # type: ignore[reportUnknownVariableType]
@runtime_checkable
class HttpRequestHandler(Protocol):
"""Protocol for HTTP request handlers used by ``HttpRequestAction``.
Implementations must be safe to call concurrently from multiple workflow
runs. Implementations are responsible for any URL allowlisting, SSRF
guards, retry policies, auth resolution, and other policies that the
workflow author wants applied.
"""
async def send(self, info: HttpRequestInfo) -> HttpRequestResult:
"""Dispatch ``info`` and return the response result.
Args:
info: Description of the request to send.
Returns:
The response. Implementations should NOT raise on non-2xx status
codes; instead, set ``is_success_status_code`` accordingly. They
SHOULD raise on transport-level failures (connection refused,
DNS errors, timeouts).
"""
...
ClientProvider = Callable[[HttpRequestInfo], Awaitable["httpx.AsyncClient | None"]]
class DefaultHttpRequestHandler:
"""Default :class:`HttpRequestHandler` backed by :class:`httpx.AsyncClient`.
Construction modes:
1. ``DefaultHttpRequestHandler()`` — owns an internal client created lazily
on first ``send()``. Closed by :meth:`aclose`.
2. ``DefaultHttpRequestHandler(client=existing)`` — caller-owned client.
Not closed by :meth:`aclose`.
3. ``DefaultHttpRequestHandler(client_provider=cb)`` — per-request client
lookup (parity with .NET's ``httpClientProvider`` callback). The
provider may return ``None`` to fall back to the owned/default client.
.. warning::
This handler performs **no** URL filtering or SSRF protection. Wrap or
replace it with a custom handler in production.
"""
def __init__(
self,
*,
client: httpx.AsyncClient | None = None,
client_provider: ClientProvider | None = None,
) -> None:
self._owned_client: httpx.AsyncClient | None = None
self._caller_client = client
self._client_provider = client_provider
# Guards lazy creation of ``_owned_client`` against concurrent first
# ``send()`` calls leaking duplicate clients.
self._owned_client_lock = asyncio.Lock()
async def send(self, info: HttpRequestInfo) -> HttpRequestResult:
"""Dispatch the request and return the parsed result."""
if not info.url:
raise ValueError("HttpRequestInfo.url must be a non-empty string.")
if not info.method:
raise ValueError("HttpRequestInfo.method must be a non-empty string.")
client = await self._resolve_client(info)
timeout: httpx.Timeout | object
if info.timeout_ms is not None and info.timeout_ms > 0:
timeout = httpx.Timeout(info.timeout_ms / 1000.0)
else:
timeout = httpx.USE_CLIENT_DEFAULT
headers = dict(info.headers)
content: bytes | str | None = None
if info.body is not None:
content = info.body
if not _has_header(headers, "content-type"):
# Match .NET DefaultHttpRequestHandler: when a body is sent
# without an explicit content type, default to ``text/plain``
# so the request is interpretable by servers and direct
# callers (not just the YAML executor) get sensible defaults.
headers["Content-Type"] = info.body_content_type or "text/plain"
params: Mapping[str, str] | None = info.query_parameters or None
response = await client.request(
method=info.method,
url=info.url,
params=params,
headers=headers or None,
content=content,
timeout=timeout,
)
# Preserve multi-value headers (e.g. multiple Set-Cookie) as list[str].
# Normalize names to lowercase so lookups are consistent and case
# variations from the transport do not create duplicate logical keys
# (HTTP headers are case-insensitive per RFC 7230 §3.2).
result_headers: dict[str, list[str]] = {}
for key, value in response.headers.multi_items():
result_headers.setdefault(key.lower(), []).append(value)
body_text = response.text
return HttpRequestResult(
status_code=response.status_code,
is_success_status_code=200 <= response.status_code < 300,
body=body_text,
headers=result_headers,
)
async def aclose(self) -> None:
"""Release the owned client, if any. Caller-owned clients are NOT closed."""
if self._owned_client is not None:
await self._owned_client.aclose()
self._owned_client = None
async def _resolve_client(self, info: HttpRequestInfo) -> httpx.AsyncClient:
"""Pick a client for this request: provider → caller → lazily-owned."""
if self._client_provider is not None:
provided = await self._client_provider(info)
if provided is not None:
return provided
if self._caller_client is not None:
return self._caller_client
if self._owned_client is None:
# Double-checked locking under asyncio.Lock so concurrent first
# callers don't each create a fresh httpx.AsyncClient and orphan
# one of them.
async with self._owned_client_lock:
if self._owned_client is None:
self._owned_client = httpx.AsyncClient()
return self._owned_client
async def __aenter__(self) -> DefaultHttpRequestHandler:
return self
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
await self.aclose()
def _has_header(headers: Mapping[str, str], name: str) -> bool:
"""Case-insensitive header presence check."""
needle = name.lower()
return any(key.lower() == needle for key in headers)
@@ -0,0 +1,581 @@
# Copyright (c) Microsoft. All rights reserved.
"""MCP tool handler abstraction for declarative workflows.
Mirrors the .NET ``IMcpToolHandler`` / ``DefaultMcpToolHandler`` pair from
``Microsoft.Agents.AI.Workflows.Declarative.Mcp``. Provides:
- :class:`MCPToolInvocation` — request input data passed from the executor.
- :class:`MCPToolResult` — response data returned to the executor.
- :class:`MCPToolHandler` — :class:`typing.Protocol` callers implement to plug
in custom transports (e.g. with allowlisting, Foundry connection resolution,
per-server auth, etc.).
- :class:`DefaultMCPToolHandler` — production-grade default backed by
:class:`agent_framework.MCPStreamableHTTPTool`.
Security note: :class:`DefaultMCPToolHandler` performs **no** URL filtering or
SSRF protection. Production deployments should supply a custom handler that
enforces an allowlist or DNS-rebinding-resistant policy. This split mirrors the
.NET design.
Prompt-injection note: MCP tool outputs flow back into agent conversations
(via ``conversationId`` and Tool-role messages emitted by the executor) so
they share the same risk surface as ``HttpRequestAction``. Workflow authors
must trust the MCP server they invoke.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
from collections import OrderedDict
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, ClassVar, Protocol, cast, runtime_checkable
import httpx
if TYPE_CHECKING:
from agent_framework import Content
__all__ = [
"ClientProvider",
"DefaultMCPToolHandler",
"MCPToolHandler",
"MCPToolInvocation",
"MCPToolResult",
]
logger = logging.getLogger(__name__)
_DEFAULT_CACHE_MAX_SIZE = 32
@dataclass
class MCPToolInvocation:
"""Description of an MCP tool call to be dispatched by a :class:`MCPToolHandler`.
Mirrors the input parameters of the .NET ``IMcpToolHandler.InvokeToolAsync``
method. Field semantics:
- ``server_url``: Absolute URL of the MCP server. Already evaluated from
the YAML expression.
- ``server_label``: Optional human-readable label used for diagnostics
and as the underlying ``MCPStreamableHTTPTool`` name.
- ``tool_name``: Name of the tool to invoke on the MCP server.
- ``arguments``: Tool arguments. Already evaluated; values may be any
JSON-serialisable Python object (str, int, bool, dict, list, None).
- ``headers``: Outbound HTTP headers (e.g. authentication). Empty values
are skipped by the executor before construction.
- ``connection_name``: Optional Foundry connection name forwarded for
handlers that resolve auth/credentials by connection. The default
handler does not consume this field.
"""
server_url: str
tool_name: str
server_label: str | None = None
arguments: dict[str, Any] = field(default_factory=dict) # type: ignore[reportUnknownVariableType]
headers: dict[str, str] = field(default_factory=dict) # type: ignore[reportUnknownVariableType]
connection_name: str | None = None
def _empty_outputs() -> list[Any]:
"""Default factory for ``MCPToolResult.outputs``.
Typed as ``list[Any]`` here to keep the dataclass field's runtime
factory simple; the public type on :class:`MCPToolResult` is
``list[Content]``.
"""
return []
@dataclass
class MCPToolResult:
"""Response returned by an :class:`MCPToolHandler`.
Mirrors the .NET ``McpServerToolResultContent`` shape. ``outputs`` is a
list of :class:`agent_framework.Content` items as parsed by the MCP
transport (TextContent / DataContent / UriContent / etc.).
On error, ``is_error`` is ``True``, ``error_message`` carries a human
readable description, and ``outputs`` typically contains a single
``Content.from_text("Error: ...")`` entry for downstream display.
"""
outputs: list[Content] = field(default_factory=_empty_outputs)
is_error: bool = False
error_message: str | None = None
@runtime_checkable
class MCPToolHandler(Protocol):
"""Protocol for MCP tool handlers used by ``InvokeMcpTool``.
Mirrors :class:`HttpRequestHandler` — declares ONLY the invocation method.
Lifecycle methods (``aclose`` / ``__aenter__`` / ``__aexit__``) are NOT
part of the Protocol; concrete implementations may add them as
appropriate.
Implementations must be safe to call concurrently from multiple workflow
runs. Implementations are responsible for any URL allowlisting, SSRF
guards, retry policies, auth resolution, and other policies the workflow
author wants applied.
"""
async def invoke_tool(self, invocation: MCPToolInvocation) -> MCPToolResult:
"""Dispatch ``invocation`` and return the result.
Args:
invocation: Description of the MCP tool call to perform.
Returns:
The :class:`MCPToolResult` carrying the parsed outputs (or an
error flag if the tool raised). Implementations SHOULD return a
result with ``is_error=True`` rather than raising for transport
or tool-level failures, so the workflow can store the message in
``output.result`` (matching .NET ``AssignErrorAsync`` behaviour).
They MAY raise on unexpected programming errors — these will be
propagated unchanged by the executor so they fail loudly.
"""
...
ClientProvider = Callable[[MCPToolInvocation], Awaitable["httpx.AsyncClient | None"]]
@dataclass
class _CacheEntry:
"""Internal record stored in the LRU cache."""
tool: Any # MCPStreamableHTTPTool — typed Any to avoid import at module load
owned_httpx_client: httpx.AsyncClient | None
class DefaultMCPToolHandler:
"""Default :class:`MCPToolHandler` backed by :class:`agent_framework.MCPStreamableHTTPTool`.
Caches one :class:`agent_framework.MCPStreamableHTTPTool` instance per
``(server_url, server_label, connection_name, headers_hash)`` in a
bounded LRU. The cache prevents re-establishing an MCP session for every
invocation while ensuring different header sets (auth tokens) cannot
share a session — matches the .NET design intent while bounding
cardinality. ``server_label`` and ``connection_name`` participate in
the key so that callers using ``client_provider`` to dispatch on those
fields receive a fresh client per logical connection (see below).
Header *names* are lower-cased inside the hash payload only — the
headers passed on the wire keep the caller's original casing — so two
YAML actions that spell ``Authorization`` differently still share a
cache entry.
Construction modes:
1. ``DefaultMCPToolHandler()`` — owns its own ``httpx.AsyncClient``
instances created lazily per cache entry. Closed by :meth:`aclose`.
2. ``DefaultMCPToolHandler(client_provider=cb)`` — per-server client
lookup (parity with .NET ``httpClientProvider`` callback). The
callback receives the full :class:`MCPToolInvocation` so it can
dispatch on ``server_url`` / ``connection_name`` / ``server_label``.
Returning ``None`` falls back to an internally-created client. Caller
supplied clients are NOT closed by :meth:`aclose`.
.. warning::
This handler performs **no** URL filtering or SSRF protection. Wrap
or replace it with a custom handler in production deployments.
Args:
client_provider: Optional per-server ``httpx.AsyncClient`` provider.
cache_max_size: Maximum number of cached MCP clients. When exceeded,
the least-recently-used entry is evicted and its client closed
(only owned clients are closed; caller-supplied ones are not).
Defaults to ``32``.
"""
LIST_TOOLS_TOOL_NAME: ClassVar[str] = "tools/list"
"""Reserved ``tool_name`` that maps an :class:`MCPToolHandler` invocation
to the MCP protocol ``tools/list`` discovery operation.
The constant matches the underlying MCP method name so a single
string travels unchanged through host code, YAML, and the protocol
wire. When this handler receives an invocation with this name it
pages through ``session.list_tools()`` and returns the catalog as a
single ``TextContent`` containing JSON of shape
``{"tools": [{name, description, inputSchema, outputSchema}, ...]}``.
Workflows can reference this name from an ``InvokeMcpTool`` declarative
action to introspect a server's tool surface without an extra round-trip
from host code.
"""
def __init__(
self,
*,
client_provider: ClientProvider | None = None,
cache_max_size: int = _DEFAULT_CACHE_MAX_SIZE,
) -> None:
if cache_max_size <= 0:
raise ValueError(f"cache_max_size must be positive, got {cache_max_size}")
self._client_provider = client_provider
self._cache_max_size = cache_max_size
self._cache: OrderedDict[tuple[str, str, str, str], _CacheEntry] = OrderedDict()
# Outer lock guards the cache + in-flight-future map only — never
# held across network I/O.
self._cache_lock = asyncio.Lock()
# Per-key in-flight futures: while one task is connecting, other
# tasks awaiting the same key will await the same future and share
# the resulting cache entry.
self._inflight: dict[tuple[str, str, str, str], asyncio.Future[_CacheEntry]] = {}
# Set by ``aclose`` to prevent post-close cache insertions and to
# reject new ``invoke_tool`` calls. Once set, never cleared.
self._closed = False
async def invoke_tool(self, invocation: MCPToolInvocation) -> MCPToolResult:
"""Invoke ``invocation.tool_name`` on the cached MCP client for the server.
The reserved name :attr:`LIST_TOOLS_TOOL_NAME` (``"tools/list"``) is
intercepted client-side: instead of being forwarded as a tool call,
it is translated to an MCP ``session.list_tools()`` discovery
operation (paginated automatically) and returned as a single
``TextContent`` containing a JSON tool catalog.
"""
from agent_framework import Content
from agent_framework.exceptions import ToolExecutionException
# Reserved-name args validation runs before connect: rejecting bad
# input shouldn't require establishing an MCP session.
if invocation.tool_name == self.LIST_TOOLS_TOOL_NAME and invocation.arguments:
message = f"The reserved MCP '{self.LIST_TOOLS_TOOL_NAME}' operation does not accept tool arguments."
return MCPToolResult(
outputs=[Content.from_text(f"Error: {message}")],
is_error=True,
error_message=message,
)
try:
entry = await self._get_or_create_entry(invocation)
except Exception as exc:
# Connect / cache lookup failures surface as tool errors so the
# workflow can store them at output.result without crashing.
logger.warning(
"DefaultMCPToolHandler: failed to obtain MCP client for url=%s tool=%s: %s",
invocation.server_url,
invocation.tool_name,
exc,
)
message = f"Failed to connect to MCP server: {type(exc).__name__}: {exc}".rstrip(": ")
return MCPToolResult(
outputs=[Content.from_text(f"Error: {message}")],
is_error=True,
error_message=message,
)
try:
if invocation.tool_name == self.LIST_TOOLS_TOOL_NAME:
return await self._invoke_list_tools(entry)
raw = await entry.tool.call_tool(invocation.tool_name, **invocation.arguments)
except ToolExecutionException as exc:
logger.info(
"DefaultMCPToolHandler: tool '%s' on '%s' raised ToolExecutionException",
invocation.tool_name,
invocation.server_url,
)
message = str(exc) or type(exc).__name__
return MCPToolResult(
outputs=[Content.from_text(f"Error: {message}")],
is_error=True,
error_message=message,
)
except httpx.HTTPError as exc:
message = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
return MCPToolResult(
outputs=[Content.from_text(f"Error: {message}")],
is_error=True,
error_message=message,
)
except Exception as exc:
# Be defensive about MCP errors that may bubble up without being
# wrapped in ToolExecutionException by custom parsers.
try:
from mcp.shared.exceptions import McpError
except ImportError: # pragma: no cover - mcp is a hard dep but stay defensive
raise
if isinstance(exc, McpError):
message = str(exc) or type(exc).__name__
return MCPToolResult(
outputs=[Content.from_text(f"Error: {message}")],
is_error=True,
error_message=message,
)
raise
# Defensive normalisation: call_tool is typed ``str | list[Content]``.
# Default parser returns list, but custom parse_tool_results may return str.
if isinstance(raw, str):
outputs: list[Content] = [Content.from_text(raw)]
else:
outputs = list(raw)
return MCPToolResult(outputs=outputs)
@staticmethod
async def _invoke_list_tools(entry: _CacheEntry) -> MCPToolResult:
"""Handle the reserved :attr:`LIST_TOOLS_TOOL_NAME` invocation.
Pages through ``session.list_tools()`` (mirroring the pagination loop
in :meth:`agent_framework.MCPTool.load_tools`) and serialises the
full catalog as a single ``TextContent`` containing JSON of shape
``{"tools": [{name, description, inputSchema, outputSchema}, ...]}``.
The output shape, property names, and property order are stable so
downstream PowerFx expressions can rely on the schema. ``indent=2``
produces human-readable JSON for the conversation log;
``allow_nan=False`` guards against producing non-conformant JSON
``NaN``/``Infinity`` tokens if a misbehaving server returns such
values in a schema.
"""
from agent_framework import Content
session = getattr(entry.tool, "session", None)
if session is None:
message = "MCP session is not connected; cannot list tools."
return MCPToolResult(
outputs=[Content.from_text(f"Error: {message}")],
is_error=True,
error_message=message,
)
# Lazy import keeps ``mcp`` types out of module import time.
from mcp import types as mcp_types
collected: list[Any] = []
params: mcp_types.PaginatedRequestParams | None = None
while True:
tool_list = await session.list_tools(params=params)
collected.extend(tool_list.tools)
next_cursor = getattr(tool_list, "nextCursor", None)
if not next_cursor:
break
params = mcp_types.PaginatedRequestParams(cursor=next_cursor)
payload = {
"tools": [
{
"name": tool.name,
"description": tool.description,
"inputSchema": tool.inputSchema,
"outputSchema": tool.outputSchema,
}
for tool in collected
],
}
return MCPToolResult(outputs=[Content.from_text(json.dumps(payload, indent=2, allow_nan=False))])
async def aclose(self) -> None:
"""Close all cached MCP clients and the owned httpx clients.
Caller-supplied :class:`httpx.AsyncClient` instances (returned by the
``client_provider`` callback) are NOT closed.
Idempotent — a second call returns immediately. Drains any in-flight
``_create_entry`` tasks before returning so their resources are
cleaned up; the in-flight tasks see ``self._closed`` in phase 3 of
:meth:`_get_or_create_entry`, close their own entry, and resolve
their future with ``RuntimeError("DefaultMCPToolHandler is closed")``.
"""
async with self._cache_lock:
if self._closed:
return
self._closed = True
entries = list(self._cache.values())
self._cache.clear()
inflight_futures = list(self._inflight.values())
# Wait for in-flight creations to finish their self-cleanup. Each
# in-flight task self-closes its entry under the closed-flag branch
# in phase 3 and resolves its future with ``RuntimeError``; we
# swallow it here because the failure is expected at shutdown.
for fut in inflight_futures:
try:
await fut
except BaseException:
logger.debug("DefaultMCPToolHandler: in-flight future raised during aclose", exc_info=True)
continue
for entry in entries:
await self._close_entry(entry)
async def __aenter__(self) -> DefaultMCPToolHandler:
return self
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
await self.aclose()
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
async def _get_or_create_entry(self, invocation: MCPToolInvocation) -> _CacheEntry:
"""Look up (or create) the cached MCP client for this invocation."""
key = self._cache_key(
invocation.server_url,
invocation.server_label,
invocation.connection_name,
invocation.headers,
)
# Phase 1: check the cache and either claim creation or wait for an
# already in-flight creation.
creating = False
async with self._cache_lock:
if self._closed:
raise RuntimeError("DefaultMCPToolHandler is closed")
existing = self._cache.get(key)
if existing is not None:
self._cache.move_to_end(key)
return existing
inflight = self._inflight.get(key)
if inflight is None:
inflight = asyncio.get_running_loop().create_future()
self._inflight[key] = inflight
creating = True
if not creating:
return await inflight
# Phase 2: we own creation. Build the entry outside the lock.
try:
entry = await self._create_entry(invocation)
except BaseException as exc:
async with self._cache_lock:
self._inflight.pop(key, None)
if not inflight.done():
inflight.set_exception(exc if isinstance(exc, BaseException) else RuntimeError(str(exc)))
# Mark the exception retrieved to suppress noisy "Future exception
# was never retrieved" warnings when there are no other awaiters
# (other awaiters still see the exception through their ``await``).
inflight.exception()
raise
# Phase 3: insert with LRU eviction; resolve the in-flight future.
# If ``aclose`` ran while we were connecting, ``_closed`` is now
# True; don't insert into the cache (it has been drained), close
# the just-built entry, and surface the closed-handler error to
# all awaiters of the future.
evicted: _CacheEntry | None = None
duplicate: _CacheEntry | None = None
handler_closed = False
async with self._cache_lock:
self._inflight.pop(key, None)
if self._closed:
handler_closed = True
else:
existing = self._cache.get(key)
if existing is not None:
# Another writer beat us; prefer the existing entry and
# discard ours after the lock is released.
self._cache.move_to_end(key)
duplicate = entry
entry = existing
else:
self._cache[key] = entry
self._cache.move_to_end(key)
if len(self._cache) > self._cache_max_size:
_evicted_key, evicted = self._cache.popitem(last=False)
if not inflight.done():
inflight.set_result(entry)
if handler_closed:
# Close our orphaned entry; resolve the future with a clear
# error so the caller (and any other awaiters) surface a
# consistent "handler is closed" failure rather than receiving
# an entry we are about to close behind their back.
await self._close_entry(entry)
err = RuntimeError("DefaultMCPToolHandler is closed")
if not inflight.done():
inflight.set_exception(err)
inflight.exception()
raise err
if duplicate is not None:
await self._close_entry(duplicate)
if evicted is not None:
await self._close_entry(evicted)
return entry
async def _create_entry(self, invocation: MCPToolInvocation) -> _CacheEntry:
"""Construct (and connect) a fresh MCP client for ``invocation``."""
from agent_framework import MCPStreamableHTTPTool
provided_client: httpx.AsyncClient | None = None
if self._client_provider is not None:
provided_client = await self._client_provider(invocation)
# Capture headers for this cache entry so the header_provider closure
# always returns the same set, regardless of the runtime kwargs.
captured_headers = dict(invocation.headers)
def _header_provider(_kwargs: dict[str, Any]) -> dict[str, str]:
return captured_headers
tool: Any = MCPStreamableHTTPTool(
name=invocation.server_label or "McpClient",
url=invocation.server_url,
load_prompts=False,
http_client=provided_client,
header_provider=_header_provider if captured_headers else None,
)
try:
await tool.connect()
except BaseException:
try:
await tool.close()
except Exception: # pragma: no cover - best effort
logger.debug("DefaultMCPToolHandler: error closing tool after failed connect", exc_info=True)
raise
# ``MCPStreamableHTTPTool.get_mcp_client`` lazily creates an
# ``httpx.AsyncClient`` when no caller client was provided AND a
# ``header_provider`` was set. We treat any client allocated this
# way as owned (closed by the handler). When the caller supplies
# one, we never close it.
owned_client: httpx.AsyncClient | None = None
if provided_client is None:
owned_client = cast("httpx.AsyncClient | None", getattr(tool, "_httpx_client", None))
return _CacheEntry(tool=tool, owned_httpx_client=owned_client)
async def _close_entry(self, entry: _CacheEntry) -> None:
"""Close the MCP tool and any owned httpx client."""
try:
await entry.tool.close()
except Exception: # pragma: no cover - best effort
logger.debug("DefaultMCPToolHandler: error closing MCP tool", exc_info=True)
if entry.owned_httpx_client is not None:
try:
await entry.owned_httpx_client.aclose()
except Exception: # pragma: no cover - best effort
logger.debug("DefaultMCPToolHandler: error closing owned httpx client", exc_info=True)
@staticmethod
def _cache_key(
server_url: str,
server_label: str | None,
connection_name: str | None,
headers: dict[str, str] | None,
) -> tuple[str, str, str, str]:
"""Build an order-independent cache key for the invocation identity.
The key includes ``server_label`` and ``connection_name`` so that
callers using ``client_provider`` to dispatch on those fields
receive a fresh client per logical connection (matches the
documented dispatch contract).
Header *names* are lower-cased inside the hash payload only so
that ``Authorization`` and ``authorization`` map to the same
cache entry. Header values remain case-sensitive (per RFC 7235).
"""
if not headers:
headers_hash = "0"
else:
normalized = sorted((k.lower(), v) for k, v in headers.items())
payload = json.dumps(normalized, ensure_ascii=False)
headers_hash = hashlib.sha256(payload.encode("utf-8")).hexdigest()
return (server_url, server_label or "", connection_name or "", headers_hash)
@@ -0,0 +1,498 @@
# 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 __future__ import annotations
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
text_attr = getattr(content, "text", None)
if text_attr is not None:
return str(text_attr)
return str(content) if content else ""
if isinstance(messages, list):
# List of messages - concatenate all text
texts: list[str] = []
message_list = cast(list[Any], messages)
for msg in message_list:
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))
else:
msg_obj: object = msg
if hasattr(msg_obj, "content"):
msg_obj_content: Any = getattr(msg_obj, "content", None)
if isinstance(msg_obj_content, str):
texts.append(msg_obj_content)
elif (msg_obj_text := getattr(msg_obj_content, "text", None)) is not None:
texts.append(str(msg_obj_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 agent_message(text: str) -> dict[str, str]:
"""Create an agent/assistant message object.
This is equivalent to the .NET AgentMessage() function.
It's an alias for assistant_message() for .NET compatibility.
Args:
text: The text content of the message
Returns:
A message dictionary with role 'assistant'
Examples:
.. code-block:: python
agent_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, dict)):
return len(value) == 0 # type: ignore[reportUnknownArgumentType]
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,
"AgentMessage": agent_message, # .NET compatibility alias for AssistantMessage
"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,
}
@@ -0,0 +1,650 @@
# 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 __future__ import annotations
import logging
import uuid
from collections.abc import Mapping
from typing import Any, cast
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 = logging.getLogger("agent_framework.declarative")
class WorkflowState:
"""Manages variables and state during declarative workflow execution.
WorkflowState provides a unified interface for:
- Reading workflow inputs (immutable after initialization)
- Managing Local-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
- Local: Variables that persist within the current workflow turn
- System: System-level variables (ConversationId, LastMessage, etc.)
- 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 Local-scoped variables
state.set("Local.results", [])
state.append("Local.results", "item1")
state.append("Local.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._local: dict[str, Any] = {}
self._outputs: dict[str, Any] = {}
conversation_id = str(uuid.uuid4())
self._system: dict[str, Any] = {
"ConversationId": conversation_id,
"LastMessage": {"Text": "", "Id": ""},
"LastMessageText": "",
"LastMessageId": "",
"conversations": {
conversation_id: {"id": conversation_id, "messages": []},
},
}
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 local(self) -> dict[str, Any]:
"""Get the Local-scoped variables."""
return self._local
@property
def system(self) -> dict[str, Any]:
"""Get the System-scoped variables."""
return self._system
@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 'Local.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
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 = self._inputs
elif sub_namespace == "Outputs":
obj = self._outputs
else:
return default
elif namespace == "Local":
obj = self._local
elif namespace == "System":
obj = self._system
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 'Local.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
namespace = parts[0]
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]
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 == "Local":
target = self._local
elif namespace == "System":
target = self._system
elif namespace == "Agent":
target = self._agent
elif namespace == "Conversation":
target = self._conversation
else:
# Create or use custom namespace
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_list = cast(list[Any], existing)
existing_list.append(value)
self.set(path, existing_list)
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 Message 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()
"""
symbols = {
"Workflow": {
"Inputs": dict(self._inputs),
"Outputs": dict(self._outputs),
},
"Local": dict(self._local),
"System": dict(self._system),
"Agent": dict(self._agent),
"Conversation": dict(self._conversation),
# Also expose inputs at top level for backward compatibility with =inputs.X syntax
"inputs": dict(self._inputs),
**self._custom,
}
# Debug log the Local symbols to help diagnose type issues
if self._local:
for key, value in self._local.items():
logger.debug(
f"PowerFx symbol Local.{key}: type={type(value).__name__}, "
f"value_preview={str(value)[:100] if value else None}"
)
return symbols
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, Workflow.Inputs.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:
# For known namespaces, return None if not found (PowerFx semantics)
# rather than the formula string
if formula.startswith(("Local.", "Workflow.", "Agent.", "Conversation.", "System.")):
return self.get(formula)
not_found = object()
value = self.get(formula, 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()} # type: ignore[reportUnknownVariableType]
if isinstance(value, list):
return [self.eval_if_expression(item) for item in value] # type: ignore[reportUnknownVariableType]
return value
def reset_local(self) -> None:
"""Reset Local-scoped variables for a new turn.
This clears the Local namespace while preserving other state.
"""
self._local.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._local = copy.copy(self._local)
new_state._system = copy.copy(self._system)
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
+107
View File
@@ -0,0 +1,107 @@
[project]
name = "agent-framework-declarative"
description = "Declarative specification support for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0rc2"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.9.0,<2",
"httpx>=0.27,<1",
"powerfx>=0.0.32,<0.0.35; python_version < '3.14'",
"pyyaml>=6.0,<7.0",
]
[dependency-groups]
dev = [
"types-PyYaml==6.0.12.20260518"
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = [
"ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*",
"ignore::agent_framework._feature_stage.ExperimentalWarning",
]
timeout = 120
markers = [
"integration: marks tests as integration tests that require external services",
]
[tool.ruff]
extend = "../../pyproject.toml"
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extends = "../../pyproject.toml"
exclude = ['tests']
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
exclude = [
'_models.py$',
]
[tool.bandit]
targets = ["agent_framework_declarative"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks.mypy]
help = "Run MyPy for this package."
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_declarative"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --cov=agent_framework_declarative --cov-report=term-missing:skip-covered tests'
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
@@ -0,0 +1,20 @@
# Copyright (c) Microsoft. All rights reserved.
"""Pytest configuration for declarative tests."""
import sys
import pytest
# Skip all tests in this directory on Python 3.14+ because powerfx doesn't support it yet
if sys.version_info >= (3, 14):
collect_ignore_glob = ["test_*.py"]
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
"""Skip all declarative tests on Python 3.14+ due to powerfx incompatibility."""
if sys.version_info >= (3, 14):
skip_marker = pytest.mark.skip(reason="powerfx does not support Python 3.14+")
for item in items:
if "declarative" in str(item.fspath):
item.add_marker(skip_marker)
@@ -0,0 +1,528 @@
# Copyright (c) Microsoft. All rights reserved.
# pyright: reportUnknownParameterType=false, reportUnknownArgumentType=false
# pyright: reportMissingParameterType=false, reportUnknownMemberType=false
# pyright: reportPrivateUsage=false, reportUnknownVariableType=false
# pyright: reportGeneralTypeIssues=false
"""Regression tests pinning the approval-flow binding contract.
The resumed invocation MUST come from the framework-delivered
``original_request`` payload (the data the reviewer approved) for both
``InvokeFunctionTool`` and ``InvokeMcpTool``. These tests verify that:
* Invocation parameters come from ``original_request``, not from any prior
side-channel state.
* Concurrent pending approvals on the same executor do not swap.
* Pre-existing state at old approval keys is ignored entirely.
* Resume works on a freshly constructed executor (checkpoint-restore
simulation), without any prior ``ctx.state`` write.
* For MCP, ``connection_name`` is sourced from the approval payload and
``headers`` are re-evaluated from the action definition on resume.
"""
import sys
from dataclasses import dataclass
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
try:
import powerfx # noqa: F401
_powerfx_available = True
except (ImportError, RuntimeError):
_powerfx_available = False
pytestmark = pytest.mark.skipif(
not _powerfx_available or sys.version_info >= (3, 14),
reason="PowerFx engine not available (requires dotnet runtime)",
)
from agent_framework import Content # noqa: E402
from agent_framework_declarative._workflows import ( # noqa: E402
DECLARATIVE_STATE_KEY,
ActionComplete,
InvokeFunctionToolExecutor,
MCPToolApprovalRequest,
MCPToolHandler,
MCPToolInvocation,
MCPToolResult,
ToolApprovalRequest,
ToolApprovalResponse,
)
from agent_framework_declarative._workflows._declarative_base import DeclarativeWorkflowState # noqa: E402
from agent_framework_declarative._workflows._executors_mcp import ( # noqa: E402
InvokeMcpToolActionExecutor,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_state() -> MagicMock:
"""In-memory mock of the underlying State."""
state = MagicMock()
state._data = {}
def _get(key: str, default: Any = None) -> Any:
return state._data.get(key, default)
def _set(key: str, value: Any) -> None:
state._data[key] = value
def _has(key: str) -> bool:
return key in state._data
def _delete(key: str) -> None:
state._data.pop(key, None)
state.get = MagicMock(side_effect=_get)
state.set = MagicMock(side_effect=_set)
state.has = MagicMock(side_effect=_has)
state.delete = MagicMock(side_effect=_delete)
return state
@pytest.fixture
def mock_context(mock_state: MagicMock) -> MagicMock:
ctx = MagicMock()
ctx.state = mock_state
ctx.send_message = AsyncMock()
ctx.yield_output = AsyncMock()
ctx.request_info = AsyncMock()
return ctx
def _seed_state(mock_state: MagicMock) -> None:
mock_state._data[DECLARATIVE_STATE_KEY] = {
"Inputs": {},
"Outputs": {},
"Local": {},
"Custom": {},
"System": {
"ConversationId": "00000000-0000-0000-0000-000000000000",
"LastMessage": {"Text": "", "Id": ""},
"LastMessageText": "",
"LastMessageId": "",
},
"Agent": {},
"Conversation": {"messages": [], "history": []},
}
class _RecordingMcpHandler(MCPToolHandler):
def __init__(self, result: MCPToolResult | None = None) -> None:
self.result = result or MCPToolResult(outputs=[Content.from_text("ok")])
self.invocations: list[MCPToolInvocation] = []
@property
def call_count(self) -> int:
return len(self.invocations)
@property
def last(self) -> MCPToolInvocation | None:
return self.invocations[-1] if self.invocations else None
async def invoke_tool(self, invocation: MCPToolInvocation) -> MCPToolResult:
self.invocations.append(invocation)
return self.result
# ---------------------------------------------------------------------------
# InvokeFunctionTool: approval-binding regression
# ---------------------------------------------------------------------------
class TestFunctionToolApprovalBinding:
def _action(self, *, fn_name: str = "my_tool") -> dict[str, Any]:
return {
"kind": "InvokeFunctionTool",
"id": "fn_action",
"functionName": fn_name,
"requireApproval": True,
"output": {"result": "Local.result"},
}
@pytest.mark.asyncio
async def test_request_id_matches_framework_pending_key(self, mock_state, mock_context) -> None:
"""The id on the emitted ToolApprovalRequest must match the framework's pending-request key."""
from agent_framework_declarative._workflows._declarative_base import ActionTrigger
_seed_state(mock_state)
def my_tool(x: int) -> int:
return x
executor = InvokeFunctionToolExecutor(self._action(), tools={"my_tool": my_tool})
await executor.handle_action(ActionTrigger(), mock_context)
mock_context.request_info.assert_called_once()
emitted_request = mock_context.request_info.call_args[0][0]
framework_request_id = mock_context.request_info.call_args.kwargs["request_id"]
assert isinstance(emitted_request, ToolApprovalRequest)
assert emitted_request.request_id == framework_request_id
@pytest.mark.asyncio
async def test_resume_uses_request_payload_arguments(self, mock_state, mock_context) -> None:
_seed_state(mock_state)
call_log: list[int] = []
def my_tool(x: int) -> int:
call_log.append(x)
return x
executor = InvokeFunctionToolExecutor(self._action(), tools={"my_tool": my_tool})
request = ToolApprovalRequest(request_id="r-1", function_name="my_tool", arguments={"x": 1})
await executor.handle_approval_response(request, ToolApprovalResponse(approved=True), mock_context)
assert call_log == [1]
@pytest.mark.asyncio
async def test_concurrent_pending_approvals_do_not_swap(self, mock_state, mock_context) -> None:
"""Two pending approvals, responses delivered out of order — each invocation uses its own payload."""
_seed_state(mock_state)
call_log: list[int] = []
def my_tool(x: int) -> int:
call_log.append(x)
return x
executor = InvokeFunctionToolExecutor(self._action(), tools={"my_tool": my_tool})
request_a = ToolApprovalRequest(request_id="r-A", function_name="my_tool", arguments={"x": 1})
request_b = ToolApprovalRequest(request_id="r-B", function_name="my_tool", arguments={"x": 999})
# Deliver response for B first, then for A. Each invocation must use its own payload.
await executor.handle_approval_response(request_b, ToolApprovalResponse(approved=True), mock_context)
await executor.handle_approval_response(request_a, ToolApprovalResponse(approved=True), mock_context)
assert call_log == [999, 1]
@pytest.mark.asyncio
async def test_resume_ignores_stale_state_at_old_approval_key(self, mock_state, mock_context) -> None:
"""Pre-existing state at the OLD approval key is ignored — payload wins."""
_seed_state(mock_state)
call_log: list[int] = []
def my_tool(x: int) -> int:
call_log.append(x)
return x
executor = InvokeFunctionToolExecutor(self._action(), tools={"my_tool": my_tool})
# Poison the old key shape (no longer read by the executor).
mock_state._data["_tool_approval_state_fn_action"] = {"function_name": "other", "arguments": {"x": 999}}
request = ToolApprovalRequest(request_id="r-3", function_name="my_tool", arguments={"x": 7})
await executor.handle_approval_response(request, ToolApprovalResponse(approved=True), mock_context)
assert call_log == [7]
# The poison was never read or deleted by the executor.
assert "_tool_approval_state_fn_action" in mock_state._data
@pytest.mark.asyncio
async def test_fresh_executor_resume_works(self, mock_state, mock_context) -> None:
"""Simulates checkpoint restore: a brand-new executor instance handles the approval response."""
_seed_state(mock_state)
call_log: list[int] = []
def my_tool(x: int) -> int:
call_log.append(x)
return x
# Pretend the executor that emitted the request is gone; a fresh one handles the response.
fresh = InvokeFunctionToolExecutor(self._action(), tools={"my_tool": my_tool})
request = ToolApprovalRequest(request_id="r-4", function_name="my_tool", arguments={"x": 42})
await fresh.handle_approval_response(request, ToolApprovalResponse(approved=True), mock_context)
assert call_log == [42]
mock_context.send_message.assert_called_once()
sent = mock_context.send_message.call_args[0][0]
assert isinstance(sent, ActionComplete)
@pytest.mark.asyncio
async def test_rejection_uses_request_payload_function_name(self, mock_state, mock_context) -> None:
_seed_state(mock_state)
def my_tool(x: int) -> int:
raise AssertionError("should not be called when rejected")
executor = InvokeFunctionToolExecutor(self._action(), tools={"my_tool": my_tool})
request = ToolApprovalRequest(request_id="r-5", function_name="my_tool", arguments={"x": 3})
await executor.handle_approval_response(
request, ToolApprovalResponse(approved=False, reason="not authorized"), mock_context
)
# The rejection message references the function name from the request payload.
local = mock_state._data[DECLARATIVE_STATE_KEY]["Local"]
assert local["result"]["rejected"] is True
assert local["result"]["reason"] == "not authorized"
# ---------------------------------------------------------------------------
# InvokeMcpTool: approval-binding regression
# ---------------------------------------------------------------------------
class TestMcpToolApprovalBinding:
def _action(self, *, headers: dict[str, Any] | None = None) -> dict[str, Any]:
action: dict[str, Any] = {
"kind": "InvokeMcpTool",
"id": "mcp_action",
"serverUrl": "https://mcp.example/api",
"toolName": "search",
"requireApproval": True,
"output": {"result": "Local.Result"},
}
if headers is not None:
action["headers"] = headers
return action
@pytest.mark.asyncio
async def test_request_id_matches_framework_pending_key(self, mock_state, mock_context) -> None:
"""The id on the emitted MCPToolApprovalRequest must match the framework's pending-request key."""
from agent_framework_declarative._workflows._declarative_base import ActionTrigger
_seed_state(mock_state)
executor = InvokeMcpToolActionExecutor(self._action(), mcp_tool_handler=_RecordingMcpHandler())
await executor.handle_action(ActionTrigger(), mock_context)
mock_context.request_info.assert_called_once()
emitted_request = mock_context.request_info.call_args[0][0]
framework_request_id = mock_context.request_info.call_args.kwargs["request_id"]
assert isinstance(emitted_request, MCPToolApprovalRequest)
assert emitted_request.request_id == framework_request_id
@pytest.mark.asyncio
async def test_resume_uses_request_payload_fields(self, mock_state, mock_context) -> None:
_seed_state(mock_state)
handler = _RecordingMcpHandler()
executor = InvokeMcpToolActionExecutor(self._action(), mcp_tool_handler=handler)
request = MCPToolApprovalRequest(
request_id="r-1",
tool_name="search",
server_url="https://mcp.example/api",
server_label="prod",
arguments={"q": "x"},
connection_name="conn-A",
)
await executor.handle_approval_response(request, ToolApprovalResponse(approved=True), mock_context)
assert handler.call_count == 1
inv = handler.last
assert inv is not None
assert inv.tool_name == "search"
assert inv.server_url == "https://mcp.example/api"
assert inv.server_label == "prod"
assert inv.arguments == {"q": "x"}
assert inv.connection_name == "conn-A"
@pytest.mark.asyncio
async def test_concurrent_pending_mcp_approvals_do_not_swap(self, mock_state, mock_context) -> None:
_seed_state(mock_state)
handler = _RecordingMcpHandler()
executor = InvokeMcpToolActionExecutor(self._action(), mcp_tool_handler=handler)
request_a = MCPToolApprovalRequest(
request_id="r-A",
tool_name="search",
server_url="https://mcp.example/api",
server_label=None,
arguments={"q": "alpha"},
connection_name="conn-A",
)
request_b = MCPToolApprovalRequest(
request_id="r-B",
tool_name="search",
server_url="https://mcp.example/api",
server_label=None,
arguments={"q": "beta"},
connection_name="conn-B",
)
await executor.handle_approval_response(request_b, ToolApprovalResponse(approved=True), mock_context)
await executor.handle_approval_response(request_a, ToolApprovalResponse(approved=True), mock_context)
assert handler.call_count == 2
assert handler.invocations[0].arguments == {"q": "beta"}
assert handler.invocations[0].connection_name == "conn-B"
assert handler.invocations[1].arguments == {"q": "alpha"}
assert handler.invocations[1].connection_name == "conn-A"
@pytest.mark.asyncio
async def test_headers_reevaluated_from_action_def_on_resume(self, mock_state, mock_context) -> None:
"""Headers come from the action definition (re-evaluated) so secrets are not in the payload."""
_seed_state(mock_state)
handler = _RecordingMcpHandler()
executor = InvokeMcpToolActionExecutor(
self._action(headers={"Authorization": "Bearer tk"}),
mcp_tool_handler=handler,
)
request = MCPToolApprovalRequest(
request_id="r-1",
tool_name="search",
server_url="https://mcp.example/api",
server_label=None,
arguments={"q": "x"},
connection_name=None,
)
await executor.handle_approval_response(request, ToolApprovalResponse(approved=True), mock_context)
assert handler.last is not None
assert handler.last.headers == {"Authorization": "Bearer tk"}
@pytest.mark.asyncio
async def test_mcp_resume_ignores_stale_state_at_old_approval_key(self, mock_state, mock_context) -> None:
_seed_state(mock_state)
handler = _RecordingMcpHandler()
executor = InvokeMcpToolActionExecutor(self._action(), mcp_tool_handler=handler)
mock_state._data["_mcp_tool_approval_state_mcp_action"] = {"poison": True}
request = MCPToolApprovalRequest(
request_id="r-1",
tool_name="search",
server_url="https://mcp.example/api",
server_label=None,
arguments={"q": "real"},
connection_name=None,
)
await executor.handle_approval_response(request, ToolApprovalResponse(approved=True), mock_context)
assert handler.call_count == 1
assert handler.last is not None
assert handler.last.arguments == {"q": "real"}
# The poison was never read or deleted by the executor.
assert "_mcp_tool_approval_state_mcp_action" in mock_state._data
@pytest.mark.asyncio
async def test_fresh_mcp_executor_resume_works(self, mock_state, mock_context) -> None:
"""Checkpoint-restore simulation: fresh executor handles the response."""
_seed_state(mock_state)
handler = _RecordingMcpHandler()
fresh = InvokeMcpToolActionExecutor(self._action(), mcp_tool_handler=handler)
request = MCPToolApprovalRequest(
request_id="r-1",
tool_name="search",
server_url="https://mcp.example/api",
server_label=None,
arguments={"q": "fresh"},
connection_name=None,
)
await fresh.handle_approval_response(request, ToolApprovalResponse(approved=True), mock_context)
assert handler.call_count == 1
assert handler.last is not None
assert handler.last.arguments == {"q": "fresh"}
@pytest.mark.asyncio
async def test_request_payload_carries_connection_name(self, mock_state, mock_context) -> None:
"""When emitting the approval request, connection_name flows into MCPToolApprovalRequest."""
from agent_framework_declarative._workflows._declarative_base import ActionTrigger
_seed_state(mock_state)
action = self._action()
action["connection"] = {"name": "conn-from-action"}
executor = InvokeMcpToolActionExecutor(action, mcp_tool_handler=_RecordingMcpHandler())
await executor.handle_action(ActionTrigger(), mock_context)
mock_context.request_info.assert_called_once()
request = mock_context.request_info.call_args[0][0]
assert isinstance(request, MCPToolApprovalRequest)
assert request.connection_name == "conn-from-action"
@pytest.mark.asyncio
async def test_request_payload_pins_conversation_id(self, mock_state, mock_context) -> None:
"""Evaluated ``conversationId`` is pinned in ``metadata`` at request-emit time."""
from agent_framework_declarative._workflows._declarative_base import ActionTrigger
_seed_state(mock_state)
state = DeclarativeWorkflowState(mock_state)
state.set("Local.targetConversation", "conv-original")
action = self._action()
action["conversationId"] = "=Local.targetConversation"
executor = InvokeMcpToolActionExecutor(action, mcp_tool_handler=_RecordingMcpHandler())
await executor.handle_action(ActionTrigger(), mock_context)
mock_context.request_info.assert_called_once()
request = mock_context.request_info.call_args[0][0]
assert isinstance(request, MCPToolApprovalRequest)
assert request.metadata.get("conversation_id") == "conv-original"
@pytest.mark.asyncio
async def test_resume_routes_output_to_pinned_conversation_not_mutated_state(
self, mock_state, mock_context
) -> None:
"""Output appends to the conversation pinned on ``original_request``, not the
current state evaluation."""
_seed_state(mock_state)
state = DeclarativeWorkflowState(mock_state)
state.set("System.conversations.conv-original.messages", [])
state.set("System.conversations.conv-mutated.messages", [])
state.set("Local.targetConversation", "conv-mutated")
handler = _RecordingMcpHandler(MCPToolResult(outputs=[Content.from_text("approved-output")]))
action = self._action()
action["conversationId"] = "=Local.targetConversation"
executor = InvokeMcpToolActionExecutor(action, mcp_tool_handler=handler)
original_request = MCPToolApprovalRequest(
request_id="r-1",
tool_name="search",
server_url="https://mcp.example/api",
server_label=None,
arguments={"q": "x"},
connection_name=None,
metadata={"conversation_id": "conv-original"},
)
await executor.handle_approval_response(original_request, ToolApprovalResponse(approved=True), mock_context)
assert len(state.get("System.conversations.conv-original.messages") or []) == 1
assert state.get("System.conversations.conv-mutated.messages") == []
@pytest.mark.asyncio
async def test_resume_handles_legacy_request_without_new_fields(self, mock_state, mock_context) -> None:
"""Resume tolerates payloads lacking ``connection_name`` / ``metadata`` (legacy pickle shape)."""
@dataclass
class _LegacyMCPApprovalRequest:
request_id: str
tool_name: str
server_url: str
server_label: str | None
arguments: dict[str, Any]
header_names: list[str]
_seed_state(mock_state)
handler = _RecordingMcpHandler()
executor = InvokeMcpToolActionExecutor(self._action(), mcp_tool_handler=handler)
legacy_request = _LegacyMCPApprovalRequest(
request_id="r-1",
tool_name="search",
server_url="https://mcp.example/api",
server_label=None,
arguments={"q": "x"},
header_names=[],
)
await executor.handle_approval_response(
legacy_request, # type: ignore[arg-type]
ToolApprovalResponse(approved=True),
mock_context,
)
assert handler.call_count == 1
assert handler.last is not None
assert handler.last.connection_name is None
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,364 @@
# Copyright (c) Microsoft. All rights reserved.
# pyright: reportUnknownParameterType=false, reportUnknownArgumentType=false
# pyright: reportMissingParameterType=false, reportUnknownMemberType=false
# pyright: reportPrivateUsage=false, reportUnknownVariableType=false
# pyright: reportGeneralTypeIssues=false
"""Path-segment validation tests for DeclarativeWorkflowState.
Path segments handed to ``get``/``set``/``append`` and ``{Variable.Path}``
placeholders in ``interpolate_string`` are subject to three distinct rules
that this module pins:
- **Empty segments** (e.g. ``""``, ``"Local."``, ``"Local..foo"``) are rejected
by all of ``get``/``set``/``append`` and ``interpolate_string``. ``get`` and
``interpolate_string`` return their default / leave the placeholder literal;
``set`` and ``append`` raise ``ValueError``.
- **Object-attribute segments** — segments that ``get`` would resolve via
``getattr`` because the parent is a non-dict object — must match the safe
identifier shape ``[A-Za-z][A-Za-z0-9_]*``. Other shapes are rejected with a
warning log and the default is returned.
- **Dict-keyed segments** — segments that resolve via dict lookup because the
parent is a ``dict`` — may use arbitrary non-empty string keys (e.g. UUIDs
or hyphenated identifiers like ``System.conversations.<uuid>.messages``).
"""
import logging
from dataclasses import dataclass
from typing import Any
from unittest.mock import MagicMock
import pytest
from agent_framework_declarative._workflows import DeclarativeWorkflowState
try:
import powerfx # noqa: F401
_powerfx_available = True
except (ImportError, RuntimeError):
_powerfx_available = False
_requires_powerfx = pytest.mark.skipif(not _powerfx_available, reason="PowerFx engine not available")
@pytest.fixture
def mock_state() -> MagicMock:
"""In-memory mock for the underlying State."""
ms = MagicMock()
ms._data = {}
def get(key: str, default: Any = None) -> Any:
return ms._data.get(key, default)
def set_(key: str, value: Any) -> None:
ms._data[key] = value
def has(key: str) -> bool:
return key in ms._data
def delete(key: str) -> None:
ms._data.pop(key, None)
ms.get = MagicMock(side_effect=get)
ms.set = MagicMock(side_effect=set_)
ms.has = MagicMock(side_effect=has)
ms.delete = MagicMock(side_effect=delete)
return ms
@pytest.fixture
def state(mock_state: MagicMock) -> DeclarativeWorkflowState:
s = DeclarativeWorkflowState(mock_state)
s.initialize()
return s
@dataclass
class _PlainObj:
"""Non-dict object so ``get`` falls through to attribute access."""
text: str = "hi"
# ---------------------------------------------------------------------------
# get(): invalid paths return default
# ---------------------------------------------------------------------------
class TestGetRejectsInvalidPaths:
def test_rejects_dunder_segment_via_attribute_access(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.obj", _PlainObj())
assert state.get("Local.obj.__class__") is None
assert state.get("Local.obj.__class__", default="DEF") == "DEF"
def test_rejects_full_env_exfil_chain(self, state: DeclarativeWorkflowState, monkeypatch) -> None:
sentinel = "agent-framework-path-safety-sentinel"
monkeypatch.setenv("AF_PATH_SAFETY_SENTINEL", sentinel)
state.set("Local.obj", _PlainObj())
result = state.get("Local.obj.__class__.__init__.__globals__.os.environ")
assert result is None
assert sentinel not in str(result)
def test_rejects_leading_underscore_via_attribute_access(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.obj", _PlainObj())
assert state.get("Local.obj._private") is None
def test_rejects_invalid_chars_via_attribute_access(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.obj", _PlainObj())
assert state.get("Local.obj.text bar") is None
assert state.get("Local.obj.text-bar") is None
def test_rejects_empty_path_and_empty_segments(self, state: DeclarativeWorkflowState) -> None:
assert state.get("") is None
assert state.get(".") is None
assert state.get("Local.") is None
assert state.get(".Local") is None
def test_warning_logged_on_rejected_attribute_segment(
self,
state: DeclarativeWorkflowState,
caplog: pytest.LogCaptureFixture,
) -> None:
state.set("Local.obj", _PlainObj())
with caplog.at_level(logging.WARNING, logger="agent_framework_declarative._workflows._declarative_base"):
state.get("Local.obj.__class__")
assert any("rejecting attribute segment" in r.message for r in caplog.records)
def test_dict_keyed_dunder_is_not_attribute_access(self, state: DeclarativeWorkflowState) -> None:
"""A literal dunder dict key is harmless because dict lookup never reaches getattr."""
state.set("Local.bag", {"__class__": "harmless-string"})
assert state.get("Local.bag.__class__") == "harmless-string"
# ---------------------------------------------------------------------------
# get(): legitimate paths continue to work
# ---------------------------------------------------------------------------
class TestGetAllowsValidPaths:
def test_underscore_inside_identifier(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.user_input", "ok")
assert state.get("Local.user_input") == "ok"
def test_mixed_case_identifiers(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.UserInput", "u1")
state.set("Local.userInput", "u2")
assert state.get("Local.UserInput") == "u1"
assert state.get("Local.userInput") == "u2"
def test_object_attribute_traversal_still_works(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.msg", _PlainObj(text="hello"))
assert state.get("Local.msg.text") == "hello"
def test_nested_dict_traversal_still_works(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.params", {"team": {"name": "alpha"}})
assert state.get("Local.params.team.name") == "alpha"
def test_uuid_and_hyphenated_dict_keys_are_allowed(self, state: DeclarativeWorkflowState) -> None:
"""Conversation-id style paths use arbitrary dict keys (UUIDs / hyphens)."""
conv_id = "eb815014-06f1-4db6-b7c1-304ea135424f"
state.set(f"System.conversations.{conv_id}.messages", ["m1", "m2"])
assert state.get(f"System.conversations.{conv_id}.messages") == ["m1", "m2"]
# ---------------------------------------------------------------------------
# set() / append(): dict-keyed operations accept arbitrary string keys
# ---------------------------------------------------------------------------
class TestSetAndAppend:
def test_set_allows_underscore_inside_identifier(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.user_input", "ok")
assert state.get("Local.user_input") == "ok"
def test_set_allows_uuid_and_hyphenated_dict_keys(self, state: DeclarativeWorkflowState) -> None:
conv_id = "conv-test-1"
state.set(f"System.conversations.{conv_id}.messages", [])
assert state.get(f"System.conversations.{conv_id}.messages") == []
def test_append_allows_uuid_and_hyphenated_dict_keys(self, state: DeclarativeWorkflowState) -> None:
conv_id = "conv-42"
state.append(f"System.conversations.{conv_id}.messages", {"role": "user", "text": "hi"})
msgs = state.get(f"System.conversations.{conv_id}.messages")
assert msgs == [{"role": "user", "text": "hi"}]
def test_workflow_inputs_still_read_only(self, state: DeclarativeWorkflowState) -> None:
with pytest.raises(ValueError, match="read-only"):
state.set("Workflow.Inputs.x", 1)
# ---------------------------------------------------------------------------
# set() / append(): malformed paths (empty segments) raise ValueError
# ---------------------------------------------------------------------------
class TestSetRejectsInvalidPaths:
@pytest.mark.parametrize("bad_path", ["", "Local.", "Local..foo", ".Local"])
def test_set_rejects_empty_segment(self, state: DeclarativeWorkflowState, bad_path: str) -> None:
with pytest.raises(ValueError, match="empty segments are not allowed"):
state.set(bad_path, "x")
@pytest.mark.parametrize("bad_path", ["", "Local.", "Local..foo", ".Local"])
def test_append_rejects_empty_segment(self, state: DeclarativeWorkflowState, bad_path: str) -> None:
with pytest.raises(ValueError, match="empty segments are not allowed"):
state.append(bad_path, "x")
def test_set_rejection_makes_no_partial_write(self, state: DeclarativeWorkflowState) -> None:
"""Rejected set() must not create an unreachable entry in the state."""
state.set("Local.user_input", "pre")
with pytest.raises(ValueError):
state.set("Local.", "value")
local = state.get_state_data().get("Local", {})
assert "" not in local
assert local == {"user_input": "pre"}
assert state.get("Local.") is None
assert state.get("Local.user_input") == "pre"
def test_append_rejection_makes_no_partial_write(self, state: DeclarativeWorkflowState) -> None:
"""Rejected append() must not create an unreachable entry in the state."""
state.set("Local.items", ["a"])
with pytest.raises(ValueError):
state.append("Local.", "value")
local = state.get_state_data().get("Local", {})
assert "" not in local
assert local == {"items": ["a"]}
# ---------------------------------------------------------------------------
# interpolate_string(): permissive matcher; get() enforces safety
# ---------------------------------------------------------------------------
class TestInterpolateString:
def test_ignores_dunder_payload(self, state: DeclarativeWorkflowState, monkeypatch) -> None:
sentinel = "agent-framework-interp-sentinel"
monkeypatch.setenv("AF_INTERP_SENTINEL", sentinel)
state.set("Local.obj", _PlainObj())
out = state.interpolate_string("X={Local.obj.__class__.__init__.__globals__.os.environ}")
assert sentinel not in out
assert out == "X="
def test_unknown_path_reduces_to_empty(self, state: DeclarativeWorkflowState) -> None:
assert state.interpolate_string("v={Local._private}") == "v="
@pytest.mark.parametrize(
"literal",
["{foo-bar}", "{Ctrl+C}", "{not:a:path}", "{Local.}", "{}"],
)
def test_non_state_braced_tokens_left_literal(self, state: DeclarativeWorkflowState, literal: str) -> None:
assert state.interpolate_string(f"v={literal}") == f"v={literal}"
def test_allows_underscore_inside_identifier(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.user_input", "hello")
assert state.interpolate_string("v={Local.user_input}") == "v=hello"
def test_resolves_nested_dict_path(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.params", {"team": "alpha"})
assert state.interpolate_string("team={Local.params.team}") == "team=alpha"
@pytest.mark.parametrize(
("key", "value"),
[
("_id", "abc123"),
("1", "one"),
("2025", "year-bucket"),
],
)
def test_resolves_dict_keyed_segments(self, state: DeclarativeWorkflowState, key: str, value: str) -> None:
state.set("Local.bag", {key: value})
assert state.interpolate_string(f"v={{Local.bag.{key}}}") == f"v={value}"
def test_resolves_uuid_conversation_key(self, state: DeclarativeWorkflowState) -> None:
conv_id = "eb815014-06f1-4db6-b7c1-304ea135424f"
state.set(f"System.conversations.{conv_id}.messages", ["hello"])
out = state.interpolate_string(f"m={{System.conversations.{conv_id}.messages}}")
assert out == "m=['hello']"
def test_end_to_end_send_activity_payload_neutralized(
self,
state: DeclarativeWorkflowState,
monkeypatch,
) -> None:
sentinel = "agent-framework-e2e-sentinel"
monkeypatch.setenv("AF_E2E_SENTINEL", sentinel)
state.set("Local.toolResult", _PlainObj())
payload = "{Local.toolResult.__class__.__init__.__globals__.os.environ}"
evaluated = state.eval_if_expression(payload)
rendered = state.interpolate_string(evaluated) if isinstance(evaluated, str) else str(evaluated)
assert sentinel not in rendered
assert rendered == ""
# ---------------------------------------------------------------------------
# Regressions: PowerFx and internal temp-variable handling still work
# ---------------------------------------------------------------------------
@_requires_powerfx
class TestPowerFxStillWorks:
def test_simple_powerfx_expression_evaluates(self, state: DeclarativeWorkflowState) -> None:
state.set("Local.x", 6)
state.set("Local.y", 7)
assert state.eval("=Local.x * Local.y") == 42
def test_internal_temp_message_text_still_works(self, state: DeclarativeWorkflowState) -> None:
"""Long MessageText() results round-trip and the temp key is removed after eval."""
long_text = "A" * 600
state.set(
"Local.Messages",
[{"text": long_text, "contents": [{"type": "text", "text": long_text}]}],
)
result = state.eval("=Upper(MessageText(Local.Messages))")
assert result == "A" * 600
local = state.get_state_data().get("Local", {})
remaining = sorted(k for k in local if k.startswith("_TempMessageText"))
assert not remaining, f"Temporary keys remain in Local: {remaining}"
def test_message_text_eval_preserves_user_temp_value(self, state: DeclarativeWorkflowState) -> None:
"""User state at the temp key path survives a long MessageText eval."""
long_text = "A" * 600
state.set("Local._TempMessageText0", "user-important-value")
state.set(
"Local.Messages",
[{"text": long_text, "contents": [{"type": "text", "text": long_text}]}],
)
result = state.eval("=Upper(MessageText(Local.Messages))")
assert result == "A" * 600
assert state.get("Local._TempMessageText0") == "user-important-value"
def test_message_text_eval_cleans_up_on_powerfx_failure(
self,
state: DeclarativeWorkflowState,
monkeypatch,
) -> None:
"""Temp key is removed even when PowerFx evaluation raises."""
from agent_framework_declarative._workflows import _declarative_base as base
class _FailingEngine:
def eval(self, *args: Any, **kwargs: Any) -> Any:
raise RuntimeError("boom")
monkeypatch.setattr(base, "Engine", _FailingEngine)
long_text = "A" * 600
state.set(
"Local.Messages",
[{"text": long_text, "contents": [{"type": "text", "text": long_text}]}],
)
with pytest.raises(RuntimeError, match="boom"):
state.eval("=Upper(MessageText(Local.Messages))")
local = state.get_state_data().get("Local", {})
remaining = sorted(k for k in local if k.startswith("_TempMessageText"))
assert not remaining, f"Temporary keys remain in Local after PowerFx failure: {remaining}"
@@ -0,0 +1,329 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for ``DefaultHttpRequestHandler``.
These tests exercise the real handler against ``httpx.MockTransport`` (no real
network) to cover the parts of the handler not exercisable through the executor
stub: query-param URL composition, content-type forwarding, per-request
timeout overrides, multi-value response header preservation, and client
ownership semantics.
"""
from __future__ import annotations
import sys
import httpx
import pytest
try:
import powerfx # noqa: F401
_powerfx_available = True
except (ImportError, RuntimeError):
_powerfx_available = False
# These tests don't actually need PowerFx, but the rest of the suite gates on
# Python versions and we keep behaviour consistent.
pytestmark = pytest.mark.skipif(
sys.version_info >= (3, 14),
reason="Skipped on Python 3.14+ to keep parity with rest of declarative suite",
)
from agent_framework_declarative._workflows._http_handler import ( # noqa: E402
DefaultHttpRequestHandler,
HttpRequestInfo,
)
def _make_handler(transport: httpx.MockTransport) -> DefaultHttpRequestHandler:
"""Return a handler with a MockTransport-backed caller-owned client."""
client = httpx.AsyncClient(transport=transport)
return DefaultHttpRequestHandler(client=client)
class TestRequestComposition:
@pytest.mark.asyncio
async def test_query_parameters_merged_into_url(self) -> None:
captured: dict[str, httpx.Request] = {}
def respond(request: httpx.Request) -> httpx.Response:
captured["req"] = request
return httpx.Response(200, text="ok")
handler = _make_handler(httpx.MockTransport(respond))
try:
await handler.send(
HttpRequestInfo(
method="GET",
url="https://api.example.test/items",
query_parameters={"q": "alpha", "limit": "5"},
)
)
finally:
await handler.aclose()
req = captured["req"]
# httpx exposes the merged URL with QS appended
assert req.url.params.get("q") == "alpha"
assert req.url.params.get("limit") == "5"
@pytest.mark.asyncio
async def test_body_content_type_forwarded(self) -> None:
captured: dict[str, httpx.Request] = {}
def respond(request: httpx.Request) -> httpx.Response:
captured["req"] = request
return httpx.Response(204)
handler = _make_handler(httpx.MockTransport(respond))
try:
await handler.send(
HttpRequestInfo(
method="POST",
url="https://api.example.test/items",
body='{"k":"v"}',
body_content_type="application/json",
)
)
finally:
await handler.aclose()
req = captured["req"]
assert req.headers.get("content-type") == "application/json"
assert req.content == b'{"k":"v"}'
@pytest.mark.asyncio
async def test_existing_content_type_header_not_overwritten(self) -> None:
captured: dict[str, httpx.Request] = {}
def respond(request: httpx.Request) -> httpx.Response:
captured["req"] = request
return httpx.Response(200, text="ok")
handler = _make_handler(httpx.MockTransport(respond))
try:
await handler.send(
HttpRequestInfo(
method="POST",
url="https://api.example.test/items",
headers={"Content-Type": "application/xml"}, # caller wins
body="<x/>",
body_content_type="application/json",
)
)
finally:
await handler.aclose()
req = captured["req"]
assert req.headers.get("content-type") == "application/xml"
@pytest.mark.asyncio
async def test_body_without_content_type_defaults_to_text_plain(self) -> None:
"""Match .NET DefaultHttpRequestHandler: body without explicit content type → ``text/plain``."""
captured: dict[str, httpx.Request] = {}
def respond(request: httpx.Request) -> httpx.Response:
captured["req"] = request
return httpx.Response(204)
handler = _make_handler(httpx.MockTransport(respond))
try:
await handler.send(
HttpRequestInfo(
method="POST",
url="https://api.example.test/items",
body="hello",
# No body_content_type and no Content-Type header.
)
)
finally:
await handler.aclose()
req = captured["req"]
assert req.headers.get("content-type") == "text/plain"
assert req.content == b"hello"
class TestTimeout:
@pytest.mark.asyncio
async def test_per_request_timeout_surfaces_as_timeout_exception(self) -> None:
def respond(request: httpx.Request) -> httpx.Response:
raise httpx.TimeoutException("simulated timeout", request=request)
handler = _make_handler(httpx.MockTransport(respond))
try:
with pytest.raises(httpx.TimeoutException):
await handler.send(
HttpRequestInfo(
method="GET",
url="https://api.example.test/slow",
timeout_ms=50,
)
)
finally:
await handler.aclose()
class TestResponseHeaders:
@pytest.mark.asyncio
async def test_multi_value_headers_preserved(self) -> None:
def respond(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
text="ok",
headers=[
("Content-Type", "application/json"),
("Set-Cookie", "a=1"),
("Set-Cookie", "b=2"),
],
)
handler = _make_handler(httpx.MockTransport(respond))
try:
result = await handler.send(HttpRequestInfo(method="GET", url="https://api.example.test/x"))
finally:
await handler.aclose()
assert result.is_success_status_code
# The handler keeps multi-value headers as list[str].
assert result.headers.get("set-cookie") == ["a=1", "b=2"]
assert result.headers.get("content-type") == ["application/json"]
class TestClientOwnership:
@pytest.mark.asyncio
async def test_owned_client_is_closed_on_aclose(self) -> None:
handler = DefaultHttpRequestHandler()
# Inject a MockTransport-backed client into the owned slot and verify
# aclose() releases it. Avoids real network access.
owned = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, text="ok")))
handler._owned_client = owned
assert not owned.is_closed
await handler.aclose()
assert owned.is_closed
@pytest.mark.asyncio
async def test_caller_owned_client_is_not_closed(self) -> None:
client = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, text="ok")))
handler = DefaultHttpRequestHandler(client=client)
await handler.send(HttpRequestInfo(method="GET", url="https://api.example.test/x"))
await handler.aclose()
assert not client.is_closed
await client.aclose() # cleanup
@pytest.mark.asyncio
async def test_concurrent_first_send_creates_single_owned_client(self) -> None:
"""Concurrent first-send calls must not race-leak duplicate clients.
Without the lock, two concurrent calls on a fresh handler would each
observe ``_owned_client is None`` and create their own
``httpx.AsyncClient``, orphaning one. Verify that lazy initialization
is serialized: all concurrent sends end up using the same client and
``aclose()`` cleanly closes it.
"""
import asyncio
# Patch httpx.AsyncClient to count constructions, but only when called
# from inside _resolve_client (no transport=) so we don't break the
# MockTransport-backed clients used elsewhere.
original_ctor = httpx.AsyncClient
construction_count = 0
def counting_ctor(*args, **kwargs): # type: ignore[no-untyped-def]
nonlocal construction_count
if not args and not kwargs:
construction_count += 1
return original_ctor(transport=httpx.MockTransport(lambda r: httpx.Response(200, text="ok")))
return original_ctor(*args, **kwargs)
import agent_framework_declarative._workflows._http_handler as hh
hh.httpx.AsyncClient = counting_ctor # type: ignore[assignment] # ty: ignore[invalid-assignment]
try:
handler = DefaultHttpRequestHandler()
try:
await asyncio.gather(*[
handler.send(HttpRequestInfo(method="GET", url="https://api.example.test/x")) for _ in range(8)
])
finally:
await handler.aclose()
finally:
hh.httpx.AsyncClient = original_ctor # type: ignore[assignment]
assert construction_count == 1, (
f"Expected exactly 1 owned client to be lazily created but got {construction_count}"
)
class TestClientProvider:
@pytest.mark.asyncio
async def test_client_provider_overrides_default(self) -> None:
captured: dict[str, str] = {}
def primary(request: httpx.Request) -> httpx.Response:
captured["transport"] = "primary"
return httpx.Response(200, text="primary")
def provided(request: httpx.Request) -> httpx.Response:
captured["transport"] = "provided"
return httpx.Response(200, text="provided")
primary_client = httpx.AsyncClient(transport=httpx.MockTransport(primary))
provided_client = httpx.AsyncClient(transport=httpx.MockTransport(provided))
async def provider(info: HttpRequestInfo) -> httpx.AsyncClient:
return provided_client
handler = DefaultHttpRequestHandler(client=primary_client, client_provider=provider)
try:
result = await handler.send(HttpRequestInfo(method="GET", url="https://api.example.test/x"))
assert result.body == "provided"
assert captured["transport"] == "provided"
finally:
await handler.aclose()
await primary_client.aclose()
await provided_client.aclose()
@pytest.mark.asyncio
async def test_client_provider_returning_none_falls_back(self) -> None:
captured: dict[str, str] = {}
def primary(request: httpx.Request) -> httpx.Response:
captured["transport"] = "primary"
return httpx.Response(200, text="primary")
async def provider(info: HttpRequestInfo) -> httpx.AsyncClient | None:
return None
primary_client = httpx.AsyncClient(transport=httpx.MockTransport(primary))
handler = DefaultHttpRequestHandler(client=primary_client, client_provider=provider)
try:
result = await handler.send(HttpRequestInfo(method="GET", url="https://api.example.test/x"))
assert result.body == "primary"
finally:
await handler.aclose()
await primary_client.aclose()
class TestValidation:
@pytest.mark.asyncio
async def test_empty_url_raises(self) -> None:
handler = DefaultHttpRequestHandler()
with pytest.raises(ValueError):
await handler.send(HttpRequestInfo(method="GET", url=""))
@pytest.mark.asyncio
async def test_empty_method_raises(self) -> None:
handler = DefaultHttpRequestHandler()
with pytest.raises(ValueError):
await handler.send(HttpRequestInfo(method="", url="https://x.test/"))
class TestAsyncContextManager:
@pytest.mark.asyncio
async def test_context_manager_closes_owned_client(self) -> None:
async with DefaultHttpRequestHandler() as handler:
owned = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, text="ok")))
handler._owned_client = owned
assert owned.is_closed
@@ -0,0 +1,789 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for ``DefaultMCPToolHandler``.
These tests exercise the real handler against a fake ``MCPStreamableHTTPTool``
(no real MCP server, no real network) to cover the parts of the handler not
exercisable through the executor stub: cache hit/miss/eviction, concurrent
connect via in-flight futures, header isolation across cache keys,
string-result normalisation, ``load_prompts=False`` verification, and
owned-vs-caller httpx close semantics.
"""
from __future__ import annotations
import asyncio
import json
import sys
from typing import Any
from unittest.mock import patch
import httpx
import pytest
from agent_framework import Content
from agent_framework.exceptions import ToolExecutionException
from agent_framework_declarative._workflows._mcp_handler import (
DefaultMCPToolHandler,
MCPToolInvocation,
)
pytestmark = pytest.mark.skipif(
sys.version_info >= (3, 14),
reason="Skipped on Python 3.14+ to keep parity with rest of declarative suite",
)
class FakeListToolsResult: # noqa: B903 - mimics ``mcp.types.ListToolsResult`` shape, not a value type
"""Stand-in for ``mcp.types.ListToolsResult`` returned by ``session.list_tools()``."""
def __init__(self, tools: list[Any], next_cursor: str | None = None) -> None:
self.tools = tools
self.nextCursor = next_cursor
class FakeMcpTool:
"""Stand-in for an MCP ``Tool`` (subset used by ``_invoke_list_tools``)."""
def __init__(
self,
name: str,
description: str | None = None,
inputSchema: dict[str, Any] | None = None,
outputSchema: dict[str, Any] | None = None,
) -> None:
self.name = name
self.description = description
self.inputSchema = inputSchema if inputSchema is not None else {"type": "object", "properties": {}}
self.outputSchema = outputSchema
class FakeMcpSession:
"""Stand-in for ``mcp.ClientSession``.
``list_tools_pages`` lets a test enqueue multiple paginated responses;
when None (default), an empty single-page result is returned. ``list_tools_error``
raises a synthetic error on the next call when set.
"""
def __init__(self) -> None:
self.list_tools_pages: list[FakeListToolsResult] | None = None
self.list_tools_calls: list[Any] = []
self.list_tools_error: BaseException | None = None
async def list_tools(self, params: Any = None) -> FakeListToolsResult:
self.list_tools_calls.append(params)
if self.list_tools_error is not None:
raise self.list_tools_error
if self.list_tools_pages is None:
return FakeListToolsResult(tools=[])
index = len(self.list_tools_calls) - 1
if index >= len(self.list_tools_pages):
return FakeListToolsResult(tools=[])
return self.list_tools_pages[index]
class FakeTool:
"""Stand-in for ``MCPStreamableHTTPTool``.
Records constructor kwargs, tracks connect/close lifecycle, and dispatches
``call_tool`` to a per-instance handler.
"""
instances: list[FakeTool] = []
def __init__(self, **kwargs: Any) -> None:
self.kwargs = kwargs
self.connect_count = 0
self.close_count = 0
self.connect_delay: float = 0.0
self.connect_error: BaseException | None = None
self.call_handler: Any = lambda **_a: [Content.from_text("ok")]
self._httpx_client: httpx.AsyncClient | None = None
self.session: FakeMcpSession | None = None
# Mimic MCPStreamableHTTPTool: when no caller client AND header_provider
# is set, lazily allocate an owned httpx client during connect.
FakeTool.instances.append(self)
async def connect(self) -> None:
if self.connect_delay:
await asyncio.sleep(self.connect_delay)
if self.connect_error is not None:
raise self.connect_error
self.connect_count += 1
# Mimic lazy httpx allocation when no client provided AND header_provider set.
if self.kwargs.get("http_client") is None and self.kwargs.get("header_provider") is not None:
self._httpx_client = httpx.AsyncClient()
# Mimic MCPStreamableHTTPTool: a live session becomes available after connect.
if self.session is None:
self.session = FakeMcpSession()
async def close(self) -> None:
self.close_count += 1
async def call_tool(self, tool_name: str, **arguments: Any) -> Any:
return self.call_handler(tool_name=tool_name, **arguments)
@pytest.fixture(autouse=True)
def _clear_fake_instances() -> None:
FakeTool.instances.clear()
def _patch_tool() -> Any:
"""Patch the lazy import inside ``_create_entry`` to substitute FakeTool."""
import agent_framework
return patch.object(agent_framework, "MCPStreamableHTTPTool", FakeTool)
def _invocation(
*, server_url: str = "https://mcp.example/api", tool_name: str = "search", **overrides: Any
) -> MCPToolInvocation:
return MCPToolInvocation(
server_url=server_url,
tool_name=tool_name,
**overrides,
)
# ---------- Construction ---------------------------------------------------
class TestConstruction:
def test_invalid_cache_size_raises(self) -> None:
with pytest.raises(ValueError):
DefaultMCPToolHandler(cache_max_size=0)
with pytest.raises(ValueError):
DefaultMCPToolHandler(cache_max_size=-3)
# ---------- Tool kwargs ----------------------------------------------------
class TestToolKwargs:
@pytest.mark.asyncio
async def test_load_prompts_false_passed_to_tool(self) -> None:
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation())
assert len(FakeTool.instances) == 1
assert FakeTool.instances[0].kwargs["load_prompts"] is False
@pytest.mark.asyncio
async def test_server_label_used_as_tool_name(self) -> None:
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation(server_label="MyMcp"))
assert FakeTool.instances[0].kwargs["name"] == "MyMcp"
@pytest.mark.asyncio
async def test_default_tool_name_when_no_label(self) -> None:
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation(server_label=None))
assert FakeTool.instances[0].kwargs["name"] == "McpClient"
@pytest.mark.asyncio
async def test_no_header_provider_when_no_headers(self) -> None:
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation(headers={}))
assert FakeTool.instances[0].kwargs["header_provider"] is None
@pytest.mark.asyncio
async def test_header_provider_returns_captured_headers(self) -> None:
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation(headers={"Authorization": "Bearer T"}))
provider = FakeTool.instances[0].kwargs["header_provider"]
assert provider({}) == {"Authorization": "Bearer T"}
# Even if runtime kwargs change, captured headers stay the same.
assert provider({"foo": "bar"}) == {"Authorization": "Bearer T"}
# ---------- Cache behaviour ------------------------------------------------
class TestCache:
@pytest.mark.asyncio
async def test_same_url_and_headers_hit_cache(self) -> None:
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation(headers={"X": "1"}))
await handler.invoke_tool(_invocation(headers={"X": "1"}))
# One tool created, connect called once.
assert len(FakeTool.instances) == 1
assert FakeTool.instances[0].connect_count == 1
@pytest.mark.asyncio
async def test_different_headers_create_separate_entries(self) -> None:
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation(headers={"Authorization": "tk-A"}))
await handler.invoke_tool(_invocation(headers={"Authorization": "tk-B"}))
assert len(FakeTool.instances) == 2
@pytest.mark.asyncio
async def test_different_urls_create_separate_entries(self) -> None:
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation(server_url="https://mcp.a/api"))
await handler.invoke_tool(_invocation(server_url="https://mcp.b/api"))
assert len(FakeTool.instances) == 2
@pytest.mark.asyncio
async def test_lru_eviction_closes_old_entry(self) -> None:
handler = DefaultMCPToolHandler(cache_max_size=2)
with _patch_tool():
await handler.invoke_tool(_invocation(server_url="https://a/"))
await handler.invoke_tool(_invocation(server_url="https://b/"))
# Inserting a third evicts the LRU entry (the first one).
await handler.invoke_tool(_invocation(server_url="https://c/"))
assert len(FakeTool.instances) == 3
# First instance (https://a/) was evicted → close() called.
assert FakeTool.instances[0].kwargs["url"] == "https://a/"
assert FakeTool.instances[0].close_count == 1
# Other two remain in cache → not closed.
assert FakeTool.instances[1].close_count == 0
assert FakeTool.instances[2].close_count == 0
@pytest.mark.asyncio
async def test_repeated_use_keeps_lru_alive(self) -> None:
handler = DefaultMCPToolHandler(cache_max_size=2)
with _patch_tool():
await handler.invoke_tool(_invocation(server_url="https://a/"))
await handler.invoke_tool(_invocation(server_url="https://b/"))
# Touch a → b becomes LRU.
await handler.invoke_tool(_invocation(server_url="https://a/"))
# Insert c → b is evicted.
await handler.invoke_tool(_invocation(server_url="https://c/"))
# b was evicted.
b = FakeTool.instances[1]
assert b.kwargs["url"] == "https://b/"
assert b.close_count == 1
# a survived.
a = FakeTool.instances[0]
assert a.kwargs["url"] == "https://a/"
assert a.close_count == 0
@pytest.mark.asyncio
async def test_concurrent_connect_shares_one_entry(self) -> None:
"""Multiple concurrent invocations with the same key must share one tool."""
handler = DefaultMCPToolHandler()
# Slow down connect so concurrency window is observable.
original_connect = FakeTool.connect
async def slow_connect(self: FakeTool) -> None:
self.connect_delay = 0.05
await original_connect(self)
with _patch_tool(), patch.object(FakeTool, "connect", slow_connect):
results = await asyncio.gather(
handler.invoke_tool(_invocation(headers={"X": "1"})),
handler.invoke_tool(_invocation(headers={"X": "1"})),
handler.invoke_tool(_invocation(headers={"X": "1"})),
handler.invoke_tool(_invocation(headers={"X": "1"})),
)
assert all(not r.is_error for r in results)
# Only one tool was created and connected, despite 4 concurrent calls.
assert len(FakeTool.instances) == 1
assert FakeTool.instances[0].connect_count == 1
@pytest.mark.asyncio
async def test_different_connection_names_create_separate_entries(self) -> None:
"""Same URL/headers but different ``connection_name`` must dispatch separately."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation(connection_name="conn-A"))
await handler.invoke_tool(_invocation(connection_name="conn-B"))
assert len(FakeTool.instances) == 2
@pytest.mark.asyncio
async def test_different_server_labels_create_separate_entries(self) -> None:
"""Same URL/headers but different ``server_label`` must dispatch separately."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation(server_label="LabelA"))
await handler.invoke_tool(_invocation(server_label="LabelB"))
assert len(FakeTool.instances) == 2
@pytest.mark.asyncio
async def test_full_identity_match_hits_cache(self) -> None:
"""All four identity components match → single cached entry."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation(server_label="Lbl", connection_name="C", headers={"X": "1"}))
await handler.invoke_tool(_invocation(server_label="Lbl", connection_name="C", headers={"X": "1"}))
assert len(FakeTool.instances) == 1
assert FakeTool.instances[0].connect_count == 1
@pytest.mark.asyncio
async def test_header_name_case_collapses_to_one_cache_entry(self) -> None:
"""Header name spelling differences (case-only) must share a cache entry."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation(headers={"Authorization": "tk"}))
await handler.invoke_tool(_invocation(headers={"authorization": "tk"}))
await handler.invoke_tool(_invocation(headers={"AUTHORIZATION": "tk"}))
assert len(FakeTool.instances) == 1
assert FakeTool.instances[0].connect_count == 1
@pytest.mark.asyncio
async def test_header_value_case_does_not_collapse(self) -> None:
"""Header *values* remain case-sensitive (different tokens → different sessions)."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation(headers={"Authorization": "Bearer-A"}))
await handler.invoke_tool(_invocation(headers={"Authorization": "bearer-a"}))
assert len(FakeTool.instances) == 2
# ---------- Aclose semantics ----------------------------------------------
class TestAclose:
@pytest.mark.asyncio
async def test_aclose_closes_owned_clients(self) -> None:
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation(headers={"X": "1"}))
tool = FakeTool.instances[0]
owned = tool._httpx_client
assert owned is not None
await handler.aclose()
assert tool.close_count == 1
assert owned.is_closed
@pytest.mark.asyncio
async def test_aclose_does_not_close_caller_supplied_client(self) -> None:
caller_client = httpx.AsyncClient()
async def provider(_inv: MCPToolInvocation) -> httpx.AsyncClient:
return caller_client
handler = DefaultMCPToolHandler(client_provider=provider)
try:
with _patch_tool():
await handler.invoke_tool(_invocation(headers={"X": "1"}))
await handler.aclose()
assert FakeTool.instances[0].close_count == 1
# Caller client must still be usable.
assert not caller_client.is_closed
finally:
await caller_client.aclose()
@pytest.mark.asyncio
async def test_async_context_manager(self) -> None:
with _patch_tool():
async with DefaultMCPToolHandler() as handler:
await handler.invoke_tool(_invocation())
tool = FakeTool.instances[0]
assert tool.close_count == 1
@pytest.mark.asyncio
async def test_aclose_is_idempotent(self) -> None:
"""A second ``aclose`` is a no-op (no exception, no double-close)."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation(headers={"X": "1"}))
await handler.aclose()
await handler.aclose()
assert FakeTool.instances[0].close_count == 1
@pytest.mark.asyncio
async def test_invoke_after_close_returns_error_result(self) -> None:
"""Post-close ``invoke_tool`` surfaces a tool error rather than crashing."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.aclose()
result = await handler.invoke_tool(_invocation())
assert result.is_error is True
assert "closed" in (result.error_message or "").lower()
@pytest.mark.asyncio
async def test_aclose_drains_inflight_creation(self) -> None:
"""An in-flight ``_create_entry`` must not leak when ``aclose`` races with it.
Reproduces the race described in PR #5630 review-comment 3:
task A claims an inflight future and starts a slow connect; task B
runs ``aclose``; task A must self-clean (close its tool + httpx
client) and surface a closed-handler error rather than orphaning
the entry.
"""
handler = DefaultMCPToolHandler()
connect_started = asyncio.Event()
release_connect = asyncio.Event()
original_connect = FakeTool.connect
async def gated_connect(self: FakeTool) -> None:
connect_started.set()
await release_connect.wait()
await original_connect(self)
with _patch_tool(), patch.object(FakeTool, "connect", gated_connect):
invoke_task = asyncio.create_task(handler.invoke_tool(_invocation(headers={"X": "1"})))
# Wait until task A is mid-connect.
await connect_started.wait()
# Race: kick off aclose. It must wait for the in-flight task.
close_task = asyncio.create_task(handler.aclose())
# Yield once to ensure aclose has set _closed and is awaiting.
await asyncio.sleep(0)
# Allow the connect to complete; phase 3 sees _closed and self-cleans.
release_connect.set()
result = await invoke_task
await close_task
# Entry was created and then closed by the in-flight task itself.
assert len(FakeTool.instances) == 1
assert FakeTool.instances[0].close_count == 1
# The originating invocation surfaces a closed-handler error.
assert result.is_error is True
assert "closed" in (result.error_message or "").lower()
# ---------- Result normalisation ------------------------------------------
class TestResultNormalisation:
@pytest.mark.asyncio
async def test_string_result_wrapped_in_text_content(self) -> None:
handler = DefaultMCPToolHandler()
with _patch_tool():
inv = _invocation()
result = await handler.invoke_tool(inv)
# The fake's default already returns a list; replace handler for this test.
FakeTool.instances[0].call_handler = lambda **_a: "raw string body"
result = await handler.invoke_tool(inv)
assert result.is_error is False
assert len(result.outputs) == 1
assert result.outputs[0].text == "raw string body" # type: ignore[reportAttributeAccessIssue]
@pytest.mark.asyncio
async def test_list_result_passed_through(self) -> None:
handler = DefaultMCPToolHandler()
custom = [Content.from_text("a"), Content.from_text("b")]
with _patch_tool():
inv = _invocation()
await handler.invoke_tool(inv)
FakeTool.instances[0].call_handler = lambda **_a: custom
result = await handler.invoke_tool(inv)
assert result.is_error is False
assert len(result.outputs) == 2
# ---------- Error mapping --------------------------------------------------
class TestErrorMapping:
@pytest.mark.asyncio
async def test_tool_execution_exception_returns_error_result(self) -> None:
handler = DefaultMCPToolHandler()
def boom(**_a: Any) -> Any:
raise ToolExecutionException("server says no")
with _patch_tool():
inv = _invocation()
await handler.invoke_tool(inv)
FakeTool.instances[0].call_handler = boom
result = await handler.invoke_tool(inv)
assert result.is_error is True
assert result.error_message == "server says no"
text = result.outputs[0].text # type: ignore[reportAttributeAccessIssue]
assert text is not None
assert text.startswith("Error:")
@pytest.mark.asyncio
async def test_httpx_error_returns_error_result(self) -> None:
handler = DefaultMCPToolHandler()
def boom(**_a: Any) -> Any:
raise httpx.ConnectError("dns failure")
with _patch_tool():
inv = _invocation()
await handler.invoke_tool(inv)
FakeTool.instances[0].call_handler = boom
result = await handler.invoke_tool(inv)
assert result.is_error is True
assert "dns failure" in (result.error_message or "")
@pytest.mark.asyncio
async def test_unexpected_exception_propagates(self) -> None:
"""RuntimeError (not in the narrow catch list) must propagate."""
handler = DefaultMCPToolHandler()
def boom(**_a: Any) -> Any:
raise RuntimeError("programmer error")
with _patch_tool():
inv = _invocation()
await handler.invoke_tool(inv)
FakeTool.instances[0].call_handler = boom
with pytest.raises(RuntimeError, match="programmer error"):
await handler.invoke_tool(inv)
@pytest.mark.asyncio
async def test_connect_failure_returns_error_result(self) -> None:
handler = DefaultMCPToolHandler()
with (
_patch_tool(),
patch.object(
FakeTool,
"connect",
lambda self: (_ for _ in ()).throw(httpx.ConnectError("server down")),
),
):
result = await handler.invoke_tool(_invocation())
assert result.is_error is True
text = result.outputs[0].text # type: ignore[reportAttributeAccessIssue]
assert text is not None
assert text.startswith("Error:")
# Failed connect must clear in-flight + cache entries.
assert handler._inflight == {}
assert len(handler._cache) == 0
@pytest.mark.asyncio
async def test_cancelled_error_propagates(self) -> None:
"""asyncio.CancelledError is BaseException, must NOT be swallowed."""
handler = DefaultMCPToolHandler()
def boom(**_a: Any) -> Any:
raise asyncio.CancelledError
with _patch_tool():
inv = _invocation()
await handler.invoke_tool(inv)
FakeTool.instances[0].call_handler = boom
with pytest.raises(asyncio.CancelledError):
await handler.invoke_tool(inv)
# ---------- Cache key isolation -------------------------------------------
class TestCacheKey:
def test_key_order_independent(self) -> None:
k1 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"A": "1", "B": "2"})
k2 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"B": "2", "A": "1"})
assert k1 == k2
def test_key_distinguishes_values(self) -> None:
k1 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"A": "1"})
k2 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"A": "2"})
assert k1 != k2
def test_empty_headers_use_fixed_hash(self) -> None:
k1 = DefaultMCPToolHandler._cache_key("https://x/", None, None, None)
k2 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {})
assert k1 == k2
def test_key_distinguishes_connection_name(self) -> None:
k1 = DefaultMCPToolHandler._cache_key("https://x/", None, "conn-A", None)
k2 = DefaultMCPToolHandler._cache_key("https://x/", None, "conn-B", None)
assert k1 != k2
def test_key_distinguishes_server_label(self) -> None:
k1 = DefaultMCPToolHandler._cache_key("https://x/", "Lbl-A", None, None)
k2 = DefaultMCPToolHandler._cache_key("https://x/", "Lbl-B", None, None)
assert k1 != k2
def test_key_collapses_header_name_case(self) -> None:
k1 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"Authorization": "tk"})
k2 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"authorization": "tk"})
assert k1 == k2
def test_key_keeps_header_value_case(self) -> None:
k1 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"X": "Bearer-A"})
k2 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"X": "bearer-a"})
assert k1 != k2
# ---------- tools/list reserved name --------------------------------------
class TestListTools:
"""Exercise the reserved :attr:`DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME` interception path."""
@pytest.mark.asyncio
async def test_list_tools_returns_json_catalog(self) -> None:
handler = DefaultMCPToolHandler()
with _patch_tool():
# Prime the cache so the FakeTool session exists.
await handler.invoke_tool(_invocation())
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr] # ty: ignore[invalid-assignment]
FakeListToolsResult(
tools=[
FakeMcpTool(
name="search",
description="Search docs",
inputSchema={"type": "object", "properties": {"q": {"type": "string"}}},
outputSchema={"type": "object"},
),
FakeMcpTool(name="echo", description=None, outputSchema=None),
],
),
]
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
assert result.is_error is False
assert len(result.outputs) == 1
text = result.outputs[0].text # type: ignore[reportAttributeAccessIssue]
assert text is not None
payload = json.loads(text)
assert payload == {
"tools": [
{
"name": "search",
"description": "Search docs",
"inputSchema": {"type": "object", "properties": {"q": {"type": "string"}}},
"outputSchema": {"type": "object"},
},
{
"name": "echo",
"description": None,
"inputSchema": {"type": "object", "properties": {}},
"outputSchema": None,
},
],
}
@pytest.mark.asyncio
async def test_list_tools_property_order_is_stable(self) -> None:
"""JSON property order is stable: name, description, inputSchema, outputSchema."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation())
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr] # ty: ignore[invalid-assignment]
FakeListToolsResult(tools=[FakeMcpTool(name="t1", description="d")]),
]
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
text = result.outputs[0].text # type: ignore[reportAttributeAccessIssue]
assert text is not None
name_idx = text.find('"name"')
desc_idx = text.find('"description"')
input_idx = text.find('"inputSchema"')
output_idx = text.find('"outputSchema"')
assert 0 <= name_idx < desc_idx < input_idx < output_idx
@pytest.mark.asyncio
async def test_list_tools_indented_output(self) -> None:
"""Output is JSON with a 2-space indent so the conversation log is human-readable."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation())
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr] # ty: ignore[invalid-assignment]
FakeListToolsResult(tools=[FakeMcpTool(name="t1")]),
]
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
text = result.outputs[0].text # type: ignore[reportAttributeAccessIssue]
assert text is not None
# Indented output contains newlines and a 2-space indented key.
assert "\n " in text
@pytest.mark.asyncio
async def test_list_tools_rejects_arguments(self) -> None:
"""Reserved name does NOT accept tool arguments. Fails fast before connect."""
handler = DefaultMCPToolHandler()
with _patch_tool():
result = await handler.invoke_tool(
_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME, arguments={"q": "test"}),
)
assert result.is_error is True
assert "does not accept tool arguments" in (result.error_message or "")
# Args validation runs before connect, so no tool was instantiated.
assert FakeTool.instances == []
@pytest.mark.asyncio
async def test_list_tools_empty_args_dict_is_accepted(self) -> None:
"""An empty arguments dict is equivalent to no arguments."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation())
result = await handler.invoke_tool(
_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME, arguments={}),
)
assert result.is_error is False
@pytest.mark.asyncio
async def test_list_tools_paginates(self) -> None:
"""Pagination loop calls list_tools repeatedly until nextCursor is empty."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation())
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr] # ty: ignore[invalid-assignment]
FakeListToolsResult(tools=[FakeMcpTool(name="a")], next_cursor="cursor1"),
FakeListToolsResult(tools=[FakeMcpTool(name="b")], next_cursor="cursor2"),
FakeListToolsResult(tools=[FakeMcpTool(name="c")], next_cursor=None),
]
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
text = result.outputs[0].text # type: ignore[reportAttributeAccessIssue]
assert text is not None
payload = json.loads(text)
assert [t["name"] for t in payload["tools"]] == ["a", "b", "c"]
session = FakeTool.instances[0].session
assert session is not None
assert len(session.list_tools_calls) == 3
# First call has no cursor; second/third use the cursor from the prior page.
assert session.list_tools_calls[0] is None
assert getattr(session.list_tools_calls[1], "cursor", None) == "cursor1"
assert getattr(session.list_tools_calls[2], "cursor", None) == "cursor2"
@pytest.mark.asyncio
async def test_list_tools_shares_cache_with_call_tool(self) -> None:
"""tools/list reuses the same cached MCP session as a regular call_tool."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation(tool_name="search"))
await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
assert len(FakeTool.instances) == 1
assert FakeTool.instances[0].connect_count == 1
@pytest.mark.asyncio
async def test_list_tools_propagates_session_errors_as_error_result(self) -> None:
"""Errors raised by session.list_tools become MCPToolResult(is_error=True), not crashes."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation())
FakeTool.instances[0].session.list_tools_error = httpx.ReadTimeout("read timed out") # type: ignore[union-attr] # ty: ignore[invalid-assignment]
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
assert result.is_error is True
assert "ReadTimeout" in (result.error_message or "")
@pytest.mark.asyncio
async def test_list_tools_returns_error_when_session_is_none(self) -> None:
"""If somehow the cached tool has no session, return a clear error rather than crashing."""
handler = DefaultMCPToolHandler()
with _patch_tool():
await handler.invoke_tool(_invocation())
FakeTool.instances[0].session = None
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
assert result.is_error is True
assert "not connected" in (result.error_message or "")
@pytest.mark.asyncio
async def test_list_tools_does_not_call_call_tool(self) -> None:
"""The reserved name is intercepted; the inner call_tool path is bypassed."""
handler = DefaultMCPToolHandler()
call_tool_invoked = False
def fail(**_a: Any) -> Any:
nonlocal call_tool_invoked
call_tool_invoked = True
raise AssertionError("call_tool should not run for tools/list")
with _patch_tool():
await handler.invoke_tool(_invocation())
FakeTool.instances[0].call_handler = fail
FakeTool.instances[0].session.list_tools_pages = [ # type: ignore[union-attr] # ty: ignore[invalid-assignment]
FakeListToolsResult(tools=[]),
]
result = await handler.invoke_tool(_invocation(tool_name=DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME))
assert call_tool_invoked is False
assert result.is_error is False
def test_class_attribute_value(self) -> None:
# Constant must equal the MCP protocol method name so a single
# string travels unchanged through host code, YAML, and the wire.
assert DefaultMCPToolHandler.LIST_TOOLS_TOOL_NAME == "tools/list"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,375 @@
# 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 sys
import pytest
try:
import powerfx # noqa: F401
_powerfx_available = True
except (ImportError, RuntimeError):
_powerfx_available = False
pytestmark = pytest.mark.skipif(
not _powerfx_available or sys.version_info >= (3, 14),
reason="PowerFx engine not available (requires dotnet runtime)",
)
from agent_framework_declarative._workflows import ( # noqa: E402
ActionTrigger,
DeclarativeWorkflowBuilder,
)
from agent_framework_declarative._workflows._factory import WorkflowFactory # noqa: E402
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": "Local.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": "Local.flag", "value": True},
{
"kind": "If",
"id": "check_flag",
"condition": "=Local.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": "Local.items", "value": ["a", "b", "c"]},
{
"kind": "Foreach",
"id": "process_items",
"source": "=Local.items",
"itemName": "item",
"actions": [
{"kind": "SendActivity", "id": "show_item", "activity": {"text": "=Local.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_foreach_multi_action_body_runs_sequentially(self):
"""Body actions must complete per item before advancing."""
yaml_def = {
"name": "loop_sequential_body",
"actions": [
{"kind": "SetValue", "id": "set_items", "path": "Local.items", "value": ["A", "B"]},
{
"kind": "Foreach",
"id": "loop",
"source": "=Local.items",
"itemName": "item",
"actions": [
{"kind": "SendActivity", "id": "step_1", "activity": {"text": '="1-" & Local.item'}},
{"kind": "SendActivity", "id": "step_2", "activity": {"text": '="2-" & Local.item'}},
{"kind": "SendActivity", "id": "step_3", "activity": {"text": '="3-" & Local.item'}},
],
},
],
}
builder = DeclarativeWorkflowBuilder(yaml_def)
workflow = builder.build()
events = await workflow.run(ActionTrigger())
outputs = events.get_outputs()
assert outputs == ["1-A", "2-A", "3-A", "1-B", "2-B", "3-B"]
@pytest.mark.asyncio
async def test_workflow_with_condition_group(self):
"""Test workflow with ConditionGroup."""
yaml_def = {
"name": "condition_group_workflow",
"actions": [
{"kind": "SetValue", "id": "set_level", "path": "Local.level", "value": 2},
{
"kind": "ConditionGroup",
"id": "check_level",
"conditions": [
{
"condition": "=Local.level = 1",
"actions": [
{"kind": "SendActivity", "id": "level_1", "activity": {"text": "Level 1"}},
],
},
{
"condition": "=Local.level = 2",
"actions": [
{"kind": "SendActivity", "id": "level_2", "activity": {"text": "Level 2"}},
],
},
],
"elseActions": [
{"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: Local.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: Local.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": "Local.a", "value": 1},
{"kind": "SetValue", "id": "step2", "path": "Local.b", "value": 2},
{"kind": "SetValue", "id": "step3", "path": "Local.c", "value": 3},
],
}
builder = DeclarativeWorkflowBuilder(yaml_def)
_workflow = builder.build() # noqa: F841
# Verify multiple executors were created (+ _workflow_entry node)
assert "step1" in builder._executors
assert "step2" in builder._executors
assert "step3" in builder._executors
assert len(builder._executors) == 4
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 (+ _workflow_entry node)
assert len(builder._executors) == 4
# 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": "Local.value", "value": "test_data"},
{"kind": "SendActivity", "id": "send", "activity": {"text": "=Local.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": "Local.a", "value": "Hello"},
{"kind": "SetValue", "id": "set_b", "path": "Local.b", "value": "World"},
{"kind": "SendActivity", "id": "send", "activity": {"text": "=Local.a"}},
],
}
builder = DeclarativeWorkflowBuilder(yaml_def)
workflow = builder.build()
events = await workflow.run(ActionTrigger())
outputs = events.get_outputs()
assert "Hello" in outputs
@@ -0,0 +1,645 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for HttpRequestActionExecutor.
These tests use a stub HttpRequestHandler that returns canned HttpRequestResults.
No real network or httpx transports are exercised. See
test_default_http_request_handler.py for tests that exercise the real
DefaultHttpRequestHandler against httpx.MockTransport.
"""
from __future__ import annotations
import asyncio
import sys
from typing import Any
import httpx
import pytest
try:
import powerfx # noqa: F401
_powerfx_available = True
except (ImportError, RuntimeError):
_powerfx_available = False
pytestmark = pytest.mark.skipif(
not _powerfx_available or sys.version_info >= (3, 14),
reason="PowerFx engine not available (requires dotnet runtime)",
)
from agent_framework_declarative._workflows import ( # noqa: E402
DECLARATIVE_STATE_KEY,
DeclarativeActionError,
DeclarativeWorkflowError,
HttpRequestHandler,
HttpRequestInfo,
HttpRequestResult,
WorkflowFactory,
)
class StubHandler:
"""Test stub that records the last call and returns a canned result."""
def __init__(
self,
result: HttpRequestResult | None = None,
*,
raise_exc: BaseException | None = None,
) -> None:
self.result = result
self.raise_exc = raise_exc
self.last_info: HttpRequestInfo | None = None
self.call_count = 0
async def send(self, info: HttpRequestInfo) -> HttpRequestResult:
self.call_count += 1
self.last_info = info
if self.raise_exc is not None:
raise self.raise_exc
assert self.result is not None
return self.result
def _ok(body: str = "", headers: dict[str, list[str]] | None = None) -> HttpRequestResult:
return HttpRequestResult(
status_code=200,
is_success_status_code=True,
body=body,
headers=headers or {},
)
def _err(status: int = 500, body: str = "", headers: dict[str, list[str]] | None = None) -> HttpRequestResult:
return HttpRequestResult(
status_code=status,
is_success_status_code=False,
body=body,
headers=headers or {},
)
async def _run(yaml_def: dict[str, Any], handler: HttpRequestHandler) -> Any:
"""Build & run a workflow, returning final WorkflowState."""
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(yaml_def)
return await workflow.run({})
def _state(workflow: Any, events: Any) -> dict[str, Any]:
"""Read declarative state out of the workflow after run completes."""
return workflow._runner.state.get(DECLARATIVE_STATE_KEY) or {}
# Helper used by parametrised path tests
_TEST_URL = "https://api.example.test/items"
def _action(
*,
method: str | None = None,
url: str = _TEST_URL,
headers: dict[str, Any] | None = None,
query_parameters: dict[str, Any] | None = None,
body: dict[str, Any] | None = None,
response: Any = None,
response_headers: Any = None,
conversation_id: str | None = None,
request_timeout_ms: int | None = None,
connection: dict[str, Any] | None = None,
) -> dict[str, Any]:
action: dict[str, Any] = {
"kind": "HttpRequestAction",
"id": "http_action",
"url": url,
}
if method is not None:
action["method"] = method
if headers is not None:
action["headers"] = headers
if query_parameters is not None:
action["queryParameters"] = query_parameters
if body is not None:
action["body"] = body
if response is not None:
action["response"] = response
if response_headers is not None:
action["responseHeaders"] = response_headers
if conversation_id is not None:
action["conversationId"] = conversation_id
if request_timeout_ms is not None:
action["requestTimeoutInMilliseconds"] = request_timeout_ms
if connection is not None:
action["connection"] = connection
return action
def _yaml(action: dict[str, Any]) -> dict[str, Any]:
return {"name": "http_test", "actions": [action]}
# ---------- Success path: response parsing ----------------------------------
class TestSuccessPath:
@pytest.mark.asyncio
async def test_get_parses_json_object(self) -> None:
handler = StubHandler(_ok('{"key":"value","number":42}'))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(method="GET", response="Local.Result")))
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] == {"key": "value", "number": 42}
assert handler.last_info is not None
assert handler.last_info.method == "GET"
assert handler.last_info.url == _TEST_URL
@pytest.mark.asyncio
async def test_get_parses_plain_string(self) -> None:
handler = StubHandler(_ok("not-json content"))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(response="Local.Result")))
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] == "not-json content"
@pytest.mark.asyncio
async def test_get_empty_body_yields_none(self) -> None:
handler = StubHandler(_ok(""))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(response="Local.Result")))
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] is None
@pytest.mark.asyncio
async def test_response_object_form_path(self) -> None:
handler = StubHandler(_ok('{"x":1}'))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(response={"path": "Local.Result"})))
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] == {"x": 1}
@pytest.mark.asyncio
async def test_no_response_path_does_not_assign(self) -> None:
handler = StubHandler(_ok('{"x":1}'))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
# Should complete without error and without writing anything
await workflow.run({})
# ---------- Method / headers / query params --------------------------------
class TestRequestComposition:
@pytest.mark.asyncio
async def test_default_method_is_get(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
await workflow.run({})
assert handler.last_info is not None
assert handler.last_info.method == "GET"
@pytest.mark.asyncio
async def test_method_uppercased(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(method="post")))
await workflow.run({})
assert handler.last_info is not None
assert handler.last_info.method == "POST"
@pytest.mark.asyncio
async def test_headers_are_forwarded_and_empty_skipped(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(
_yaml(
_action(
headers={
"Accept": "application/json",
"X-Empty": "",
"Authorization": "Bearer token",
}
)
)
)
await workflow.run({})
assert handler.last_info is not None
assert handler.last_info.headers == {
"Accept": "application/json",
"Authorization": "Bearer token",
}
@pytest.mark.asyncio
async def test_query_parameters_stringified(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(
_yaml(
_action(
query_parameters={
"name": "alpha",
"limit": 10,
"active": True,
"ratio": 0.5,
"missing": None, # dropped
}
)
)
)
await workflow.run({})
assert handler.last_info is not None
assert handler.last_info.query_parameters == {
"name": "alpha",
"limit": "10",
"active": "true",
"ratio": "0.5",
}
# ---------- Body composition ------------------------------------------------
class TestBody:
@pytest.mark.asyncio
async def test_post_json_body_sets_content_type_and_serialises(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(
_yaml(
_action(
method="POST",
body={"kind": "json", "content": {"k": "v", "n": 1}},
)
)
)
await workflow.run({})
info = handler.last_info
assert info is not None
assert info.body_content_type == "application/json"
assert info.body is not None
# JSON serialized, key order may vary
import json
assert json.loads(info.body) == {"k": "v", "n": 1}
@pytest.mark.asyncio
async def test_post_raw_body_uses_declared_content_type(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(
_yaml(
_action(
method="POST",
body={
"kind": "raw",
"content": "raw body text",
"contentType": "text/plain",
},
)
)
)
await workflow.run({})
info = handler.last_info
assert info is not None
assert info.body == "raw body text"
assert info.body_content_type == "text/plain"
@pytest.mark.asyncio
async def test_post_raw_body_without_content_type_defaults_to_text_plain(self) -> None:
"""Match .NET RawRequestContent: no contentType => default text/plain.
Otherwise the request is sent without a Content-Type header which most
servers will treat as application/octet-stream and fail to parse.
"""
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(
_yaml(
_action(
method="POST",
body={"kind": "raw", "content": "plain body"},
)
)
)
await workflow.run({})
info = handler.last_info
assert info is not None
assert info.body == "plain body"
assert info.body_content_type == "text/plain"
@pytest.mark.asyncio
async def test_long_form_body_kinds_accepted(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(
_yaml(
_action(
method="POST",
body={"kind": "JsonRequestContent", "content": {"k": 1}},
)
)
)
await workflow.run({})
info = handler.last_info
assert info is not None
assert info.body_content_type == "application/json"
@pytest.mark.asyncio
async def test_unknown_body_kind_raises(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(body={"kind": "weirdform", "content": "x"})))
with pytest.raises(Exception) as excinfo:
await workflow.run({})
# Should surface as ValueError (potentially wrapped by runner)
msg = str(excinfo.value)
assert "weirdform" in msg or "unsupported value" in msg
@pytest.mark.asyncio
async def test_no_body_omitted(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
await workflow.run({})
info = handler.last_info
assert info is not None
assert info.body is None
assert info.body_content_type is None
# ---------- Non-2xx and error handling -------------------------------------
class TestErrorHandling:
@pytest.mark.asyncio
async def test_non_2xx_raises_declarative_action_error(self) -> None:
handler = StubHandler(_err(status=500, body="server exploded"))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
with pytest.raises(DeclarativeActionError) as excinfo:
await workflow.run({})
msg = str(excinfo.value)
assert "500" in msg
assert "server exploded" in msg
@pytest.mark.asyncio
async def test_non_2xx_long_body_truncated(self) -> None:
big_body = "A" * 1000
handler = StubHandler(_err(status=500, body=big_body))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
with pytest.raises(DeclarativeActionError) as excinfo:
await workflow.run({})
msg = str(excinfo.value)
assert "[truncated]" in msg
assert len(msg) < 512
# Should NOT contain the full 1000-char body
assert big_body not in msg
@pytest.mark.asyncio
async def test_non_2xx_empty_body_omits_body_section(self) -> None:
handler = StubHandler(_err(status=404, body=""))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
with pytest.raises(DeclarativeActionError) as excinfo:
await workflow.run({})
msg = str(excinfo.value)
assert "404" in msg
assert "Body:" not in msg
@pytest.mark.asyncio
async def test_non_2xx_control_chars_collapsed(self) -> None:
handler = StubHandler(_err(status=500, body="line1\r\nline2\tlong"))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
with pytest.raises(DeclarativeActionError) as excinfo:
await workflow.run({})
msg = str(excinfo.value)
assert "\r" not in msg
assert "\n" not in msg
assert "\t" not in msg
assert "line1 line2 long" in msg
@pytest.mark.asyncio
async def test_timeout_exception_becomes_declarative_action_error(self) -> None:
handler = StubHandler(raise_exc=httpx.TimeoutException("timeout"))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
with pytest.raises(DeclarativeActionError) as excinfo:
await workflow.run({})
assert "timed out" in str(excinfo.value)
@pytest.mark.asyncio
async def test_stdlib_timeout_error_becomes_declarative_action_error(self) -> None:
handler = StubHandler(raise_exc=TimeoutError("clock"))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
with pytest.raises(DeclarativeActionError) as excinfo:
await workflow.run({})
assert "timed out" in str(excinfo.value)
@pytest.mark.asyncio
async def test_transport_error_becomes_declarative_action_error(self) -> None:
handler = StubHandler(raise_exc=httpx.ConnectError("dns failure"))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
with pytest.raises(DeclarativeActionError) as excinfo:
await workflow.run({})
msg = str(excinfo.value)
assert "failed" in msg
assert _TEST_URL in msg
@pytest.mark.asyncio
async def test_cancelled_error_propagates_unchanged(self) -> None:
"""CancelledError from the handler must propagate so cancellation works."""
handler = StubHandler(raise_exc=asyncio.CancelledError())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
# CancelledError is allowed to surface as either CancelledError or as
# the runner's wrapped form, but it MUST NOT be DeclarativeActionError.
with pytest.raises(BaseException) as excinfo:
await workflow.run({})
assert not isinstance(excinfo.value, DeclarativeActionError)
@pytest.mark.asyncio
async def test_generic_exception_from_custom_handler_wrapped(self) -> None:
"""A custom handler raising a non-httpx Exception must be wrapped.
Authors can plug in custom HttpRequestHandler implementations that use
any transport (requests-like clients, gRPC bridges, mock test doubles,
etc.). The executor must wrap arbitrary Exception subclasses uniformly
so that workflow error handling stays consistent across transports.
"""
handler = StubHandler(raise_exc=RuntimeError("custom transport blew up"))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
with pytest.raises(DeclarativeActionError) as excinfo:
await workflow.run({})
msg = str(excinfo.value)
assert "failed" in msg
assert "RuntimeError" in msg
assert _TEST_URL in msg
# ---------- Response headers ------------------------------------------------
class TestResponseHeaders:
@pytest.mark.asyncio
async def test_response_headers_folded_with_commas(self) -> None:
handler = StubHandler(
_ok(
"ok",
headers={
"Content-Type": ["application/json"],
"Set-Cookie": ["a=1", "b=2"],
},
)
)
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(response_headers="Local.H")))
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
h = decl["Local"]["H"]
assert h["Content-Type"] == "application/json"
assert h["Set-Cookie"] == "a=1,b=2"
@pytest.mark.asyncio
async def test_response_headers_empty_assigned_none(self) -> None:
handler = StubHandler(_ok("ok", headers={}))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(response_headers="Local.H")))
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["H"] is None
@pytest.mark.asyncio
async def test_non_2xx_still_publishes_headers(self) -> None:
handler = StubHandler(_err(status=500, body="boom", headers={"X-Trace": ["abc"]}))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(response_headers="Local.H")))
with pytest.raises(DeclarativeActionError):
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["H"] == {"X-Trace": "abc"}
# ---------- ConversationId append -------------------------------------------
class TestConversationAppend:
@pytest.mark.asyncio
async def test_conversation_id_appends_message(self) -> None:
handler = StubHandler(_ok('{"answer":"hello"}'))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(
_yaml(
_action(
response="Local.Result",
conversation_id="conv-test-1",
)
)
)
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
conv = decl["System"]["conversations"].get("conv-test-1")
assert conv is not None
assert len(conv["messages"]) == 1
@pytest.mark.asyncio
async def test_empty_conversation_id_does_not_append(self) -> None:
handler = StubHandler(_ok('{"answer":"hello"}'))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(response="Local.Result", conversation_id="")))
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
# Auto-init creates an entry for the System.ConversationId conversation,
# but it should NOT have HTTP-appended messages from us.
for _cid, conv in decl["System"]["conversations"].items():
assert conv["messages"] == []
@pytest.mark.asyncio
async def test_empty_body_skips_conversation_append(self) -> None:
handler = StubHandler(_ok(""))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(conversation_id="conv-test-1")))
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
# No conversation entry should have been created either.
assert "conv-test-1" not in decl["System"]["conversations"]
# ---------- Connection name -------------------------------------------------
class TestConnection:
@pytest.mark.asyncio
async def test_connection_name_forwarded(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(connection={"name": "my-connection"})))
await workflow.run({})
assert handler.last_info is not None
assert handler.last_info.connection_name == "my-connection"
# ---------- Build-time validation -------------------------------------------
class TestBuildTimeValidation:
def test_missing_url_fails_validation(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
bad = {
"name": "no_url",
"actions": [{"kind": "HttpRequestAction", "id": "x"}],
}
with pytest.raises(DeclarativeWorkflowError):
factory.create_workflow_from_definition(bad)
def test_missing_handler_fails_at_build(self) -> None:
factory = WorkflowFactory() # no handler
with pytest.raises(DeclarativeWorkflowError) as excinfo:
factory.create_workflow_from_definition(_yaml(_action()))
assert "http_request_handler" in str(excinfo.value)
# ---------- Timeout forwarding ----------------------------------------------
class TestTimeout:
@pytest.mark.asyncio
async def test_timeout_ms_forwarded(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(request_timeout_ms=2500)))
await workflow.run({})
assert handler.last_info is not None
assert handler.last_info.timeout_ms == 2500
@pytest.mark.asyncio
async def test_timeout_ms_zero_treated_as_unset(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(request_timeout_ms=0)))
await workflow.run({})
assert handler.last_info is not None
assert handler.last_info.timeout_ms is None
@@ -0,0 +1,111 @@
# Copyright (c) Microsoft. All rights reserved.
"""End-to-end YAML integration test for ``HttpRequestAction``.
Loads the ``tests/workflows/http_request.yaml`` fixture (parity with the .NET
integration fixture) through ``WorkflowFactory.create_workflow_from_yaml_path``
with a stub :class:`HttpRequestHandler` and asserts state is populated.
"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
import pytest
try:
import powerfx # noqa: F401
_powerfx_available = True
except (ImportError, RuntimeError):
_powerfx_available = False
pytestmark = [
pytest.mark.skipif(
not _powerfx_available,
reason="powerfx not available — declarative workflows require it.",
),
pytest.mark.skipif(
sys.version_info >= (3, 14),
reason="Skipped on Python 3.14+ to keep parity with declarative suite.",
),
]
from agent_framework_declarative import WorkflowFactory # noqa: E402
from agent_framework_declarative._workflows import DECLARATIVE_STATE_KEY # noqa: E402
from agent_framework_declarative._workflows._http_handler import ( # noqa: E402
HttpRequestInfo,
HttpRequestResult,
)
FIXTURE_PATH = Path(__file__).parent / "workflows" / "http_request.yaml"
class _StubHandler:
"""Test double that records requests and returns a canned response."""
def __init__(self, result: HttpRequestResult) -> None:
self._result = result
self.received: list[HttpRequestInfo] = []
async def send(self, info: HttpRequestInfo) -> HttpRequestResult:
self.received.append(info)
return self._result
@pytest.mark.asyncio
async def test_http_request_yaml_roundtrip() -> None:
handler = _StubHandler(
HttpRequestResult(
status_code=200,
is_success_status_code=True,
body='{"name": "runtime", "visibility": "public", "stars": 12345}',
headers={
"content-type": ["application/json"],
"x-ratelimit-remaining": ["59"],
},
)
)
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_yaml_path(FIXTURE_PATH)
await workflow.run({})
decl: dict[str, Any] = workflow._runner.state.get(DECLARATIVE_STATE_KEY) or {}
local: dict[str, Any] = decl.get("Local") or {}
assert local.get("RepoOwner") == "dotnet"
repo_info = local.get("RepoInfo")
assert isinstance(repo_info, dict), f"Expected dict body, got {type(repo_info)!r}"
assert repo_info["name"] == "runtime"
assert repo_info["visibility"] == "public"
assert repo_info["stars"] == 12345
repo_headers = local.get("RepoHeaders")
assert isinstance(repo_headers, dict)
# Single-value header surfaces as plain string.
assert repo_headers.get("content-type") == "application/json"
assert repo_headers.get("x-ratelimit-remaining") == "59"
# Stub got the right call.
assert len(handler.received) == 1
sent = handler.received[0]
assert sent.method == "GET"
assert sent.url == "https://api.github.com/repos/dotnet/runtime"
assert sent.headers["Accept"] == "application/vnd.github+json"
assert sent.headers["User-Agent"] == "agent-framework-integration-test"
@pytest.mark.asyncio
async def test_http_request_yaml_missing_handler_fails_at_build_time() -> None:
"""Without an http_request_handler, building the workflow must raise."""
from agent_framework_declarative._workflows._errors import DeclarativeWorkflowError
factory = WorkflowFactory() # no handler configured
with pytest.raises(DeclarativeWorkflowError) as excinfo:
factory.create_workflow_from_yaml_path(FIXTURE_PATH)
msg = str(excinfo.value)
assert "HttpRequestAction" in msg
assert "http_request_handler" in msg
@@ -0,0 +1,631 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for ``InvokeMcpToolActionExecutor``.
Use a stub :class:`MCPToolHandler` that returns canned :class:`MCPToolResult`s.
No real MCP server or network is exercised. See
``test_default_mcp_tool_handler.py`` for tests that exercise the real
``DefaultMCPToolHandler`` against a mocked ``MCPStreamableHTTPTool``.
"""
import sys
from typing import Any
import httpx
import pytest
try:
import powerfx # noqa: F401
_powerfx_available = True
except (ImportError, RuntimeError):
_powerfx_available = False
pytestmark = pytest.mark.skipif(
not _powerfx_available or sys.version_info >= (3, 14),
reason="PowerFx engine not available (requires dotnet runtime)",
)
from agent_framework import Content, Message # noqa: E402
from agent_framework.exceptions import ToolExecutionException # noqa: E402
from agent_framework_declarative._workflows import ( # noqa: E402
DECLARATIVE_STATE_KEY,
DeclarativeWorkflowError,
MCPToolHandler,
MCPToolInvocation,
MCPToolResult,
WorkflowFactory,
)
class StubMcpHandler:
"""Test stub recording the last call and returning a canned result."""
def __init__(
self,
result: MCPToolResult | None = None,
*,
raise_exc: BaseException | None = None,
) -> None:
self.result = result
self.raise_exc = raise_exc
self.last_invocation: MCPToolInvocation | None = None
self.invocations: list[MCPToolInvocation] = []
self.call_count = 0
async def invoke_tool(self, invocation: MCPToolInvocation) -> MCPToolResult:
self.call_count += 1
self.last_invocation = invocation
self.invocations.append(invocation)
if self.raise_exc is not None:
raise self.raise_exc
assert self.result is not None
return self.result
def _ok(outputs: list[Content] | None = None) -> MCPToolResult:
return MCPToolResult(outputs=outputs or [Content.from_text("hello")])
def _err(message: str = "boom") -> MCPToolResult:
return MCPToolResult(
outputs=[Content.from_text(f"Error: {message}")],
is_error=True,
error_message=message,
)
def _action(
*,
server_url: str = "https://mcp.example/api",
tool_name: str = "search",
server_label: str | None = None,
arguments: dict[str, Any] | None = None,
headers: dict[str, Any] | None = None,
require_approval: Any = None,
connection: dict[str, Any] | None = None,
conversation_id: str | None = None,
output: dict[str, Any] | None = None,
) -> dict[str, Any]:
action: dict[str, Any] = {
"kind": "InvokeMcpTool",
"id": "mcp_action",
"serverUrl": server_url,
"toolName": tool_name,
}
if server_label is not None:
action["serverLabel"] = server_label
if arguments is not None:
action["arguments"] = arguments
if headers is not None:
action["headers"] = headers
if require_approval is not None:
action["requireApproval"] = require_approval
if connection is not None:
action["connection"] = connection
if conversation_id is not None:
action["conversationId"] = conversation_id
if output is not None:
action["output"] = output
return action
def _yaml(action: dict[str, Any]) -> dict[str, Any]:
return {"name": "mcp_test", "actions": [action]}
# ---------- Builder enforcement --------------------------------------------
class TestBuilderEnforcement:
def test_missing_handler_raises_at_build_time(self) -> None:
factory = WorkflowFactory()
with pytest.raises(DeclarativeWorkflowError) as excinfo:
factory.create_workflow_from_definition(_yaml(_action()))
assert "InvokeMcpTool" in str(excinfo.value)
assert "mcp_tool_handler" in str(excinfo.value)
def test_missing_server_url_fails_validation(self) -> None:
handler = StubMcpHandler(_ok())
factory = WorkflowFactory(mcp_tool_handler=handler)
action = _action()
del action["serverUrl"]
with pytest.raises(Exception) as excinfo:
factory.create_workflow_from_definition(_yaml(action))
assert "serverUrl" in str(excinfo.value)
def test_missing_tool_name_fails_validation(self) -> None:
handler = StubMcpHandler(_ok())
factory = WorkflowFactory(mcp_tool_handler=handler)
action = _action()
del action["toolName"]
with pytest.raises(Exception) as excinfo:
factory.create_workflow_from_definition(_yaml(action))
assert "toolName" in str(excinfo.value)
# ---------- Field forwarding ----------------------------------------------
class TestFieldForwarding:
@pytest.mark.asyncio
async def test_basic_invocation_forwards_required_fields(self) -> None:
handler = StubMcpHandler(_ok())
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
await workflow.run({})
assert handler.call_count == 1
inv = handler.last_invocation
assert inv is not None
assert inv.server_url == "https://mcp.example/api"
assert inv.tool_name == "search"
assert inv.server_label is None
assert inv.headers == {}
assert inv.arguments == {}
assert inv.connection_name is None
@pytest.mark.asyncio
async def test_arguments_evaluated_and_preserves_none(self) -> None:
handler = StubMcpHandler(_ok())
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(
_yaml(
_action(
arguments={
"query": "weather today",
"limit": 5,
"fresh": True,
"missing": None,
}
)
)
)
await workflow.run({})
inv = handler.last_invocation
assert inv is not None
# ``None`` is preserved (parity with .NET) — caller decides.
assert inv.arguments == {
"query": "weather today",
"limit": 5,
"fresh": True,
"missing": None,
}
@pytest.mark.asyncio
async def test_headers_drop_empty_values(self) -> None:
handler = StubMcpHandler(_ok())
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(
_yaml(
_action(
headers={
"Authorization": "Bearer token-123",
"X-Trace": "trace-id",
"X-Empty": "",
}
)
)
)
await workflow.run({})
inv = handler.last_invocation
assert inv is not None
assert inv.headers == {
"Authorization": "Bearer token-123",
"X-Trace": "trace-id",
}
@pytest.mark.asyncio
async def test_server_label_and_connection_name_forwarded(self) -> None:
handler = StubMcpHandler(_ok())
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(
_yaml(
_action(
server_label="docs-mcp",
connection={"name": "azure-conn"},
)
)
)
await workflow.run({})
inv = handler.last_invocation
assert inv is not None
assert inv.server_label == "docs-mcp"
assert inv.connection_name == "azure-conn"
# ---------- Output handling ------------------------------------------------
class TestOutput:
@pytest.mark.asyncio
async def test_output_result_parses_json_text(self) -> None:
handler = StubMcpHandler(_ok([Content.from_text('{"k":"v","n":1}')]))
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] == [{"k": "v", "n": 1}]
@pytest.mark.asyncio
async def test_output_result_falls_back_to_raw_text(self) -> None:
handler = StubMcpHandler(_ok([Content.from_text("plain text not json")]))
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] == ["plain text not json"]
@pytest.mark.asyncio
async def test_output_messages_writes_single_tool_role_message(self) -> None:
handler = StubMcpHandler(_ok([Content.from_text("hi"), Content.from_text("there")]))
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"messages": "Local.Messages"})))
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
msg = decl["Local"]["Messages"]
# Single Tool-role message containing both contents (parity with .NET).
assert isinstance(msg, Message)
assert str(msg.role).lower() == "tool"
assert len(msg.contents) == 2
@pytest.mark.asyncio
async def test_uri_content_serialised_as_uri_string(self) -> None:
uri_content = Content.from_uri("https://example.com/file.txt", media_type="text/plain")
handler = StubMcpHandler(_ok([uri_content]))
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] == ["https://example.com/file.txt"]
@pytest.mark.asyncio
async def test_output_path_object_form(self) -> None:
handler = StubMcpHandler(_ok([Content.from_text("ok")]))
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": {"path": "Local.Result"}})))
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] == ["ok"]
# ---------- Conversation append --------------------------------------------
class TestConversation:
@pytest.mark.asyncio
async def test_conversation_id_appends_assistant_message(self) -> None:
handler = StubMcpHandler(_ok([Content.from_text("answer")]))
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(
_yaml(
_action(
conversation_id="conv-42",
output={"result": "Local.Result"},
)
)
)
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
conv = decl["System"]["conversations"]["conv-42"]
msgs = conv["messages"] if isinstance(conv, dict) else conv.messages
assert len(msgs) == 1
appended = msgs[0]
assert str(appended.role).lower() == "assistant"
# Same contents as the tool output.
assert len(appended.contents) == 1
@pytest.mark.asyncio
async def test_empty_conversation_id_does_not_append(self) -> None:
handler = StubMcpHandler(_ok([Content.from_text("answer")]))
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(
_yaml(
_action(
conversation_id="",
output={"result": "Local.Result"},
)
)
)
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
# Empty conversation id must not produce a `""` entry under System.conversations.
conversations = decl.get("System", {}).get("conversations", {})
assert "" not in conversations
# ---------- Approval flow --------------------------------------------------
@pytest.fixture
def mock_state(): # type: ignore[no-untyped-def]
from unittest.mock import MagicMock
state = MagicMock()
state._data = {}
def _get(key: str, default: Any = None) -> Any:
if key not in state._data:
if default is not None:
return default
raise KeyError(key)
return state._data[key]
def _set(key: str, value: Any) -> None:
state._data[key] = value
def _delete(key: str) -> None:
if key in state._data:
del state._data[key]
else:
raise KeyError(key)
state.get = MagicMock(side_effect=_get)
state.set = MagicMock(side_effect=_set)
state.delete = MagicMock(side_effect=_delete)
return state
@pytest.fixture
def mock_context(mock_state): # type: ignore[no-untyped-def]
from unittest.mock import AsyncMock, MagicMock
ctx = MagicMock()
ctx.state = mock_state
ctx.send_message = AsyncMock()
ctx.yield_output = AsyncMock()
ctx.request_info = AsyncMock()
return ctx
def _seed_state(mock_state) -> None: # type: ignore[no-untyped-def]
"""Pre-seed the declarative state container as the executors expect."""
from agent_framework_declarative._workflows import DECLARATIVE_STATE_KEY
mock_state._data[DECLARATIVE_STATE_KEY] = {
"Local": {},
"Custom": {},
"Workflow": {},
"System": {
"ConversationId": "00000000-0000-0000-0000-000000000000",
"LastMessage": {"Id": "", "Text": ""},
"LastMessageText": "",
"LastMessageId": "",
},
"Agent": {},
"Conversation": {"messages": [], "history": []},
"Inputs": {},
}
class TestApprovalFlow:
@pytest.mark.asyncio
async def test_approval_required_emits_request_and_yields(self, mock_state, mock_context) -> None: # type: ignore[no-untyped-def]
from agent_framework_declarative._workflows._declarative_base import ActionTrigger
from agent_framework_declarative._workflows._executors_mcp import (
InvokeMcpToolActionExecutor,
MCPToolApprovalRequest,
)
_seed_state(mock_state)
handler = StubMcpHandler(_ok())
executor = InvokeMcpToolActionExecutor(
_action(
require_approval=True,
arguments={"q": "x"},
headers={"Authorization": "Bearer SECRET"},
output={"result": "Local.Result"},
),
mcp_tool_handler=handler,
)
await executor.handle_action(ActionTrigger(), mock_context)
# Approval request emitted.
mock_context.request_info.assert_called_once()
request = mock_context.request_info.call_args[0][0]
assert isinstance(request, MCPToolApprovalRequest)
assert request.tool_name == "search"
assert request.arguments == {"q": "x"}
assert request.header_names == ["Authorization"]
# NEVER expose the actual auth token in any field of the approval payload.
for value in request.__dict__.values():
assert "SECRET" not in str(value)
# Workflow should yield (no ActionComplete sent yet).
mock_context.send_message.assert_not_called()
# Handler not invoked yet.
assert handler.call_count == 0
@pytest.mark.asyncio
async def test_approval_response_approved_invokes_handler(self, mock_state, mock_context) -> None: # type: ignore[no-untyped-def]
from agent_framework_declarative._workflows import ActionComplete, ToolApprovalResponse
from agent_framework_declarative._workflows._executors_mcp import (
InvokeMcpToolActionExecutor,
MCPToolApprovalRequest,
)
_seed_state(mock_state)
handler = StubMcpHandler(_ok([Content.from_text('{"ok":true}')]))
executor = InvokeMcpToolActionExecutor(
_action(
require_approval=True,
headers={"Authorization": "Bearer tk"},
output={"result": "Local.Result"},
),
mcp_tool_handler=handler,
)
await executor.handle_approval_response(
MCPToolApprovalRequest(
request_id="req-1",
tool_name="search",
server_url="https://mcp.example/api",
server_label=None,
arguments={"q": "x"},
),
ToolApprovalResponse(approved=True),
mock_context,
)
assert handler.call_count == 1
inv = handler.last_invocation
assert inv is not None
# Invocation fields source from the approval request payload.
assert inv.tool_name == "search"
assert inv.server_url == "https://mcp.example/api"
assert inv.arguments == {"q": "x"}
# Headers are re-evaluated from the action definition on resume.
assert inv.headers == {"Authorization": "Bearer tk"}
# ActionComplete was sent.
mock_context.send_message.assert_called_once()
sent = mock_context.send_message.call_args[0][0]
assert isinstance(sent, ActionComplete)
@pytest.mark.asyncio
async def test_approval_response_rejected_assigns_error(self, mock_state, mock_context) -> None: # type: ignore[no-untyped-def]
from agent_framework_declarative._workflows import ToolApprovalResponse
from agent_framework_declarative._workflows._executors_mcp import (
InvokeMcpToolActionExecutor,
MCPToolApprovalRequest,
)
_seed_state(mock_state)
handler = StubMcpHandler(_ok())
executor = InvokeMcpToolActionExecutor(
_action(
require_approval=True,
output={"result": "Local.Result"},
),
mcp_tool_handler=handler,
)
await executor.handle_approval_response(
MCPToolApprovalRequest(
request_id="req-2",
tool_name="search",
server_url="https://mcp.example/api",
server_label=None,
arguments={},
),
ToolApprovalResponse(approved=False, reason="not authorized"),
mock_context,
)
assert handler.call_count == 0
# Error string assigned at output.result.
from agent_framework_declarative._workflows import DECLARATIVE_STATE_KEY
result = mock_state._data[DECLARATIVE_STATE_KEY]["Local"]["Result"]
assert result == "Error: MCP tool invocation was not approved by user."
# ---------- Error handling -------------------------------------------------
class TestErrorHandling:
@pytest.mark.asyncio
async def test_handler_returns_error_result_assigns_error_string(self) -> None:
handler = StubMcpHandler(_err("server down"))
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] == "Error: server down"
@pytest.mark.asyncio
async def test_tool_execution_exception_becomes_error_result(self) -> None:
handler = StubMcpHandler(raise_exc=ToolExecutionException("invalid arguments"))
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] == "Error: invalid arguments"
@pytest.mark.asyncio
async def test_httpx_error_becomes_error_result(self) -> None:
handler = StubMcpHandler(raise_exc=httpx.ConnectError("dns fail"))
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
result = decl["Local"]["Result"]
assert isinstance(result, str)
assert result.startswith("Error:")
assert "ConnectError" in result
@pytest.mark.asyncio
async def test_unexpected_exception_propagates(self) -> None:
"""Programmer bugs (TypeError etc.) must NOT be swallowed."""
handler = StubMcpHandler(raise_exc=TypeError("bad type"))
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
with pytest.raises(Exception) as excinfo:
await workflow.run({})
# Either the TypeError reaches us or it gets wrapped by the runner —
# either way the message must surface.
assert "bad type" in str(excinfo.value)
# ---------- autoSend -------------------------------------------------------
class TestAutoSend:
@pytest.mark.asyncio
async def test_auto_send_default_true_yields_output(self) -> None:
handler = StubMcpHandler(_ok([Content.from_text("hello")]))
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
events = await workflow.run({})
outputs = events.get_outputs()
assert len(outputs) == 1
@pytest.mark.asyncio
async def test_auto_send_false_suppresses_yield(self) -> None:
handler = StubMcpHandler(_ok([Content.from_text("hello")]))
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"autoSend": False})))
events = await workflow.run({})
outputs = events.get_outputs()
assert outputs == []
# ---------- Protocol structure --------------------------------------------
class TestProtocol:
def test_stub_handler_satisfies_protocol(self) -> None:
handler = StubMcpHandler(_ok())
assert isinstance(handler, MCPToolHandler)
# ---------- _format_outputs_for_send --------------------------------------
class TestFormatOutputsForSend:
"""Direct tests for the auto-send rendering helper.
Regression for PR #5630 review-comment 4: a single scalar JSON value
must render bare (e.g. ``"42"``) rather than wrapped (``"[42]"``).
"""
@pytest.mark.parametrize(
("parsed", "expected"),
[
([], ""),
(["hello"], "hello"),
(["a", "b"], "a\nb"),
([42], "42"),
([3.14], "3.14"),
([True], "true"),
([False], "false"),
([None], "null"),
([{"k": "v"}], '{"k": "v"}'),
([[1, 2]], "[1, 2]"),
(["hello", 42], '["hello", 42]'),
([{"a": 1}, {"b": 2}], '[{"a": 1}, {"b": 2}]'),
],
)
def test_format_outputs_for_send(self, parsed: list[Any], expected: str) -> None:
from agent_framework_declarative._workflows._executors_mcp import _format_outputs_for_send
assert _format_outputs_for_send(parsed) == expected
@@ -0,0 +1,682 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for custom PowerFx-like functions."""
from typing import cast
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(cast("str", 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",
"If",
"Or",
"And",
"Not",
"AgentMessage",
"ForAll",
]
for name in expected:
assert name in CUSTOM_FUNCTIONS
class TestMessageTextEdgeCases:
"""Additional tests for message_text edge cases."""
def test_message_text_dict_with_text_attr_content(self):
"""Test message with content that has text attribute."""
class ContentWithText: # noqa: B903
def __init__(self, text: str):
self.text = text
msg = {"role": "assistant", "content": ContentWithText("Hello from text attr")}
assert message_text(msg) == "Hello from text attr"
def test_message_text_dict_content_non_string(self):
"""Test message with non-string content."""
msg = {"role": "assistant", "content": 42}
assert message_text(msg) == "42"
def test_message_text_list_with_string_items(self):
"""Test message_text with list of strings."""
result = message_text(["Hello", "World"])
assert result == "Hello World"
def test_message_text_list_with_content_objects(self):
"""Test message_text with list items having content attribute."""
class MessageObj: # noqa: B903
def __init__(self, content: str):
self.content = content
msgs = [MessageObj("Hello"), MessageObj("World")]
result = message_text(msgs)
assert result == "Hello World"
def test_message_text_list_with_content_text_attr(self):
"""Test message_text with content having text attribute."""
class ContentWithText: # noqa: B903
def __init__(self, text: str):
self.text = text
class MessageObj:
def __init__(self, content):
self.content = content
msgs = [MessageObj(ContentWithText("Part1")), MessageObj(ContentWithText("Part2"))]
result = message_text(msgs)
assert result == "Part1 Part2"
def test_message_text_list_with_non_string_content(self):
"""Test message_text with non-string content in dicts."""
msgs = [{"content": 123}, {"content": 456}]
result = message_text(msgs)
assert result == "123 456"
def test_message_text_object_with_text_attr(self):
"""Test message_text with object having text attribute."""
class ObjWithText:
text = "Direct text"
result = message_text(ObjWithText())
assert result == "Direct text"
def test_message_text_object_with_content_attr(self):
"""Test message_text with object having content attribute."""
class ObjWithContent:
content = "Direct content"
result = message_text(ObjWithContent())
assert result == "Direct content"
def test_message_text_object_with_non_string_content(self):
"""Test message_text with object having non-string content."""
class ObjWithContent:
content = None
result = message_text(ObjWithContent())
assert result == ""
def test_message_text_list_with_empty_content_object(self):
"""Test message with content object that evaluates to empty."""
class MessageObj:
content = None
result = message_text([MessageObj()])
assert result == ""
class TestAgentMessage:
"""Tests for agent_message function."""
def test_agent_message_creates_dict(self):
"""Test that AgentMessage creates correct dict."""
from agent_framework_declarative._workflows._powerfx_functions import agent_message
msg = agent_message("Hello")
assert msg == {"role": "assistant", "content": "Hello"}
def test_agent_message_with_none(self):
"""Test AgentMessage with None."""
from agent_framework_declarative._workflows._powerfx_functions import agent_message
msg = agent_message(cast("str", None))
assert msg == {"role": "assistant", "content": ""}
class TestIfFunc:
"""Tests for if_func conditional function."""
def test_if_true_condition(self):
"""Test If with true condition."""
from agent_framework_declarative._workflows._powerfx_functions import if_func
assert if_func(True, "yes", "no") == "yes"
def test_if_false_condition(self):
"""Test If with false condition."""
from agent_framework_declarative._workflows._powerfx_functions import if_func
assert if_func(False, "yes", "no") == "no"
def test_if_truthy_value(self):
"""Test If with truthy value."""
from agent_framework_declarative._workflows._powerfx_functions import if_func
assert if_func(1, "yes", "no") == "yes"
assert if_func("non-empty", "yes", "no") == "yes"
def test_if_falsy_value(self):
"""Test If with falsy value."""
from agent_framework_declarative._workflows._powerfx_functions import if_func
assert if_func(0, "yes", "no") == "no"
assert if_func("", "yes", "no") == "no"
assert if_func(None, "yes", "no") == "no"
def test_if_no_false_value(self):
"""Test If with no false value defaults to None."""
from agent_framework_declarative._workflows._powerfx_functions import if_func
assert if_func(False, "yes") is None
class TestOrFunc:
"""Tests for or_func function."""
def test_or_all_false(self):
"""Test Or with all false values."""
from agent_framework_declarative._workflows._powerfx_functions import or_func
assert or_func(False, False, False) is False
def test_or_one_true(self):
"""Test Or with one true value."""
from agent_framework_declarative._workflows._powerfx_functions import or_func
assert or_func(False, True, False) is True
def test_or_all_true(self):
"""Test Or with all true values."""
from agent_framework_declarative._workflows._powerfx_functions import or_func
assert or_func(True, True, True) is True
def test_or_empty(self):
"""Test Or with no arguments."""
from agent_framework_declarative._workflows._powerfx_functions import or_func
assert or_func() is False
class TestAndFunc:
"""Tests for and_func function."""
def test_and_all_true(self):
"""Test And with all true values."""
from agent_framework_declarative._workflows._powerfx_functions import and_func
assert and_func(True, True, True) is True
def test_and_one_false(self):
"""Test And with one false value."""
from agent_framework_declarative._workflows._powerfx_functions import and_func
assert and_func(True, False, True) is False
def test_and_all_false(self):
"""Test And with all false values."""
from agent_framework_declarative._workflows._powerfx_functions import and_func
assert and_func(False, False, False) is False
def test_and_empty(self):
"""Test And with no arguments."""
from agent_framework_declarative._workflows._powerfx_functions import and_func
assert and_func() is True
class TestNotFunc:
"""Tests for not_func function."""
def test_not_true(self):
"""Test Not with true."""
from agent_framework_declarative._workflows._powerfx_functions import not_func
assert not_func(True) is False
def test_not_false(self):
"""Test Not with false."""
from agent_framework_declarative._workflows._powerfx_functions import not_func
assert not_func(False) is True
def test_not_truthy(self):
"""Test Not with truthy values."""
from agent_framework_declarative._workflows._powerfx_functions import not_func
assert not_func(1) is False
assert not_func("text") is False
def test_not_falsy(self):
"""Test Not with falsy values."""
from agent_framework_declarative._workflows._powerfx_functions import not_func
assert not_func(0) is True
assert not_func("") is True
assert not_func(None) is True
class TestIsBlankEdgeCases:
"""Additional tests for is_blank edge cases."""
def test_is_blank_empty_dict(self):
"""Test that empty dict is blank."""
assert is_blank({}) is True
def test_is_blank_non_empty_dict(self):
"""Test that non-empty dict is not blank."""
assert is_blank({"key": "value"}) is False
class TestCountRowsEdgeCases:
"""Additional tests for count_rows edge cases."""
def test_count_rows_dict(self):
"""Test counting dict items."""
assert count_rows({"a": 1, "b": 2, "c": 3}) == 3
def test_count_rows_tuple(self):
"""Test counting tuple items."""
assert count_rows((1, 2, 3, 4)) == 4
def test_count_rows_non_iterable(self):
"""Test counting non-iterable returns 0."""
assert count_rows(42) == 0
assert count_rows("string") == 0
class TestFirstLastEdgeCases:
"""Additional tests for first/last edge cases."""
def test_first_none(self):
"""Test first with None."""
assert first(None) is None
def test_last_none(self):
"""Test last with None."""
assert last(None) is None
def test_first_tuple(self):
"""Test first with tuple."""
assert first((1, 2, 3)) == 1
def test_last_tuple(self):
"""Test last with tuple."""
assert last((1, 2, 3)) == 3
class TestFindEdgeCases:
"""Additional tests for find edge cases."""
def test_find_none_substring(self):
"""Test find with None substring."""
assert find(None, "text") is None
def test_find_none_text(self):
"""Test find with None text."""
assert find("sub", None) is None
def test_find_both_none(self):
"""Test find with both None."""
assert find(None, None) is None
class TestLowerEdgeCases:
"""Additional tests for lower edge cases."""
def test_lower_none(self):
"""Test lower with None."""
assert lower(None) == ""
class TestConcatStrings:
"""Tests for concat_strings function."""
def test_concat_strings_basic(self):
"""Test basic string concatenation."""
from agent_framework_declarative._workflows._powerfx_functions import concat_strings
assert concat_strings("Hello", " ", "World") == "Hello World"
def test_concat_strings_with_none(self):
"""Test concat with None values."""
from agent_framework_declarative._workflows._powerfx_functions import concat_strings
assert concat_strings("Hello", None, "World") == "HelloWorld"
def test_concat_strings_empty(self):
"""Test concat with no arguments."""
from agent_framework_declarative._workflows._powerfx_functions import concat_strings
assert concat_strings() == ""
class TestConcatTextEdgeCases:
"""Additional tests for concat_text edge cases."""
def test_concat_text_none(self):
"""Test concat_text with None."""
assert concat_text(None) == ""
def test_concat_text_non_list(self):
"""Test concat_text with non-list."""
assert concat_text("single value") == "single value"
def test_concat_text_with_field_attr(self):
"""Test concat_text with field as object attribute."""
class Item: # noqa: B903
def __init__(self, name: str):
self.name = name
items = [Item("Alice"), Item("Bob")]
assert concat_text(items, field="name", separator=", ") == "Alice, Bob"
def test_concat_text_with_none_values(self):
"""Test concat_text with None values in list."""
items = [{"name": "Alice"}, {"name": None}, {"name": "Bob"}]
result = concat_text(items, field="name", separator=", ")
assert result == "Alice, , Bob"
class TestForAll:
"""Tests for for_all function."""
def test_for_all_with_list_of_dicts(self):
"""Test ForAll with list of dictionaries."""
from agent_framework_declarative._workflows._powerfx_functions import for_all
items = [{"name": "Alice"}, {"name": "Bob"}]
result = for_all(items, "expression")
assert result == items
def test_for_all_with_non_dict_items(self):
"""Test ForAll with non-dict items."""
from agent_framework_declarative._workflows._powerfx_functions import for_all
items = [1, 2, 3]
result = for_all(items, "expression")
assert result == [1, 2, 3]
def test_for_all_with_none(self):
"""Test ForAll with None."""
from agent_framework_declarative._workflows._powerfx_functions import for_all
assert for_all(None, "expression") == []
def test_for_all_with_non_list(self):
"""Test ForAll with non-list."""
from agent_framework_declarative._workflows._powerfx_functions import for_all
assert for_all("not a list", "expression") == []
def test_for_all_empty_list(self):
"""Test ForAll with empty list."""
from agent_framework_declarative._workflows._powerfx_functions import for_all
assert for_all([], "expression") == []
class TestSearchTableEdgeCases:
"""Additional tests for search_table edge cases."""
def test_search_table_none(self):
"""Test search_table with None."""
assert search_table(None, "value", "column") == []
def test_search_table_non_list(self):
"""Test search_table with non-list."""
assert search_table("not a list", "value", "column") == []
def test_search_table_with_object_attr(self):
"""Test search_table with object attributes."""
class Item: # noqa: B903
def __init__(self, name: str):
self.name = name
items = [Item("Alice"), Item("Bob"), Item("Charlie")]
result = search_table(items, "Bob", "name")
assert len(result) == 1
assert result[0].name == "Bob"
def test_search_table_no_matching_column(self):
"""Test search_table when items don't have the column."""
items = [{"other": "value"}]
result = search_table(items, "value", "name")
assert result == []
def test_search_table_empty_value(self):
"""Test search_table with empty search value."""
items = [{"name": "Alice"}, {"name": "Bob"}]
result = search_table(items, "", "name")
# Empty string matches everything
assert len(result) == 2
@@ -0,0 +1,59 @@
# Copyright (c) Microsoft. All rights reserved.
"""Regression tests for ``_make_powerfx_safe``.
PowerFx (via pythonnet) only accepts plain primitives, dicts, and lists.
``Enum`` instances - especially ``str``- and ``int``-subclass enums like
MAF's ``MessageRole`` - silently pass ``isinstance(v, str)`` /
``isinstance(v, int)`` checks but blow up later inside pythonnet with
``'<EnumName>' value cannot be converted to System.<X>``. These tests
pin down the Enum coercion branch so we don't regress that interop fix.
"""
from enum import Enum, IntEnum
from agent_framework_declarative._workflows._declarative_base import _make_powerfx_safe
class _StrRole(str, Enum):
USER = "user"
SYSTEM = "system"
class _IntCode(IntEnum):
ONE = 1
TWO = 2
class _PlainEnum(Enum):
X = "x"
Y = 42
def test_str_subclass_enum_reduces_to_str():
assert _make_powerfx_safe(_StrRole.USER) == "user"
assert type(_make_powerfx_safe(_StrRole.USER)) is str
def test_int_subclass_enum_reduces_to_int():
assert _make_powerfx_safe(_IntCode.ONE) == 1
assert type(_make_powerfx_safe(_IntCode.ONE)) is int
def test_plain_enum_reduces_to_underlying_value():
assert _make_powerfx_safe(_PlainEnum.X) == "x"
assert _make_powerfx_safe(_PlainEnum.Y) == 42
def test_enum_inside_dict_is_coerced():
safe = _make_powerfx_safe({"role": _StrRole.USER, "code": _IntCode.TWO})
assert safe == {"role": "user", "code": 2}
assert type(safe["role"]) is str
assert type(safe["code"]) is int
def test_enum_inside_list_is_coerced():
safe = _make_powerfx_safe([_StrRole.USER, _IntCode.ONE])
assert safe == ["user", 1]
assert type(safe[0]) is str
assert type(safe[1]) is int
@@ -0,0 +1,611 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests to ensure PowerFx evaluation supports all expressions used in declarative YAML workflows.
This test suite validates that all PowerFx expressions found in the sample YAML workflows
under samples/03-workflows/declarative/ work correctly with our implementation.
Coverage includes:
- Built-in PowerFx functions: Concat, If, IsBlank, Not, Or, Upper, Find
- Custom functions: UserMessage, MessageText
- System variables: System.ConversationId, System.LastMessage.Text
- Local/turn variables with nested access
- Comparison operators: <, >, <=, >=, <>, =
- Logical operators: And, Or, Not, !
- Arithmetic operators: +, -, *, /
- String interpolation: {Variable.Path}
"""
import locale
from unittest.mock import MagicMock
import pytest
try:
import powerfx # noqa: F401
_powerfx_available = True
except (ImportError, RuntimeError):
_powerfx_available = False
pytestmark = pytest.mark.skipif(not _powerfx_available, reason="PowerFx engine not available")
from agent_framework_declarative._workflows._declarative_base import ( # noqa: E402
DeclarativeWorkflowState,
)
class TestPowerFxBuiltinFunctions:
"""Test PowerFx built-in functions used in YAML workflows."""
@pytest.fixture
def mock_state(self):
"""Create a mock state with sync get/set methods."""
state = MagicMock()
state._data = {}
def mock_get(key, default=None):
return state._data.get(key, default)
def mock_set(key, value):
state._data[key] = value
def mock_has(key):
return key in state._data
state.get = MagicMock(side_effect=mock_get)
state.set = MagicMock(side_effect=mock_set)
state.has = MagicMock(side_effect=mock_has)
return state
async def test_concat_simple(self, mock_state):
"""Test Concat function with simple strings."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: =Concat("Nice to meet you, ", Local.userName, "!")
state.set("Local.userName", "Alice")
result = state.eval('=Concat("Nice to meet you, ", Local.userName, "!")')
assert result == "Nice to meet you, Alice!"
async def test_concat_multiple_args(self, mock_state):
"""Test Concat with multiple arguments."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: =Concat(Local.greeting, ", ", Local.name, "!")
state.set("Local.greeting", "Hello")
state.set("Local.name", "World")
result = state.eval('=Concat(Local.greeting, ", ", Local.name, "!")')
assert result == "Hello, World!"
async def test_concat_with_local_namespace(self, mock_state):
"""Test Concat using Local.* namespace (maps to Local.*)."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: =Concat("Starting math coaching session for: ", Local.Problem)
state.set("Local.Problem", "2 + 2")
result = state.eval('=Concat("Starting math coaching session for: ", Local.Problem)')
assert result == "Starting math coaching session for: 2 + 2"
async def test_if_with_isblank(self, mock_state):
"""Test If function with IsBlank."""
state = DeclarativeWorkflowState(mock_state)
state.initialize({"name": ""})
# From YAML: =If(IsBlank(inputs.name), "World", inputs.name)
# When input is blank
result = state.eval('=If(IsBlank(Workflow.Inputs.name), "World", Workflow.Inputs.name)')
assert result == "World"
# When input is provided
state.initialize({"name": "Alice"})
result = state.eval('=If(IsBlank(Workflow.Inputs.name), "World", Workflow.Inputs.name)')
assert result == "Alice"
async def test_not_function(self, mock_state):
"""Test Not function."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: =Not(Local.EscalationParameters.IsComplete)
state.set("Local.EscalationParameters", {"IsComplete": False})
result = state.eval("=Not(Local.EscalationParameters.IsComplete)")
assert result is True
state.set("Local.EscalationParameters", {"IsComplete": True})
result = state.eval("=Not(Local.EscalationParameters.IsComplete)")
assert result is False
async def test_or_function(self, mock_state):
"""Test Or function."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: =Or(Local.feeling = "great", Local.feeling = "good")
state.set("Local.feeling", "great")
result = state.eval('=Or(Local.feeling = "great", Local.feeling = "good")')
assert result is True
state.set("Local.feeling", "good")
result = state.eval('=Or(Local.feeling = "great", Local.feeling = "good")')
assert result is True
state.set("Local.feeling", "bad")
result = state.eval('=Or(Local.feeling = "great", Local.feeling = "good")')
assert result is False
async def test_upper_function(self, mock_state):
"""Test Upper function."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: =Upper(System.LastMessage.Text)
state.set("System.LastMessage", {"Text": "hello world"})
result = state.eval("=Upper(System.LastMessage.Text)")
assert result == "HELLO WORLD"
async def test_find_function(self, mock_state):
"""Test Find function."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: =!IsBlank(Find("CONGRATULATIONS", Upper(Local.TeacherResponse)))
state.set("Local.TeacherResponse", "CONGRATULATIONS! You solved it!")
result = state.eval('=Not(IsBlank(Find("CONGRATULATIONS", Upper(Local.TeacherResponse))))')
assert result is True
state.set("Local.TeacherResponse", "Try again")
result = state.eval('=Not(IsBlank(Find("CONGRATULATIONS", Upper(Local.TeacherResponse))))')
assert result is False
class TestPowerFxSystemVariables:
"""Test System.* variable access."""
@pytest.fixture
def mock_state(self):
"""Create a mock shared state."""
mock_state = MagicMock()
mock_state._data = {}
def mock_get(key, default=None):
return mock_state._data.get(key, default)
def mock_set(key, value):
mock_state._data[key] = value
mock_state.get = MagicMock(side_effect=mock_get)
mock_state.set = MagicMock(side_effect=mock_set)
return mock_state
async def test_system_conversation_id(self, mock_state):
"""Test System.ConversationId access."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: conversationId: =System.ConversationId
state.set("System.ConversationId", "conv-12345")
result = state.eval("=System.ConversationId")
assert result == "conv-12345"
async def test_system_last_message_text(self, mock_state):
"""Test System.LastMessage.Text access."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: =Upper(System.LastMessage.Text) <> "EXIT"
state.set("System.LastMessage", {"Text": "Hello"})
result = state.eval("=System.LastMessage.Text")
assert result == "Hello"
async def test_system_last_message_exit_check(self, mock_state):
"""Test the exit check pattern from YAML."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: when: =Upper(System.LastMessage.Text) <> "EXIT"
state.set("System.LastMessage", {"Text": "hello"})
result = state.eval('=Upper(System.LastMessage.Text) <> "EXIT"')
assert result is True
state.set("System.LastMessage", {"Text": "exit"})
result = state.eval('=Upper(System.LastMessage.Text) <> "EXIT"')
assert result is False
class TestPowerFxComparisonOperators:
"""Test comparison operators used in YAML workflows."""
@pytest.fixture
def mock_state(self):
"""Create a mock shared state."""
mock_state = MagicMock()
mock_state._data = {}
def mock_get(key, default=None):
return mock_state._data.get(key, default)
def mock_set(key, value):
mock_state._data[key] = value
mock_state.get = MagicMock(side_effect=mock_get)
mock_state.set = MagicMock(side_effect=mock_set)
return mock_state
async def test_less_than(self, mock_state):
"""Test < operator."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: condition: =Local.age < 65
state.set("Local.age", 30)
assert state.eval("=Local.age < 65") is True
state.set("Local.age", 70)
assert state.eval("=Local.age < 65") is False
async def test_less_than_with_local(self, mock_state):
"""Test < with Local namespace."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: condition: =Local.TurnCount < 4
state.set("Local.TurnCount", 2)
assert state.eval("=Local.TurnCount < 4") is True
state.set("Local.TurnCount", 5)
assert state.eval("=Local.TurnCount < 4") is False
async def test_equality(self, mock_state):
"""Test = equality operator."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: =Local.feeling = "great"
state.set("Local.feeling", "great")
assert state.eval('=Local.feeling = "great"') is True
state.set("Local.feeling", "bad")
assert state.eval('=Local.feeling = "great"') is False
async def test_inequality(self, mock_state):
"""Test <> inequality operator."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: =Upper(System.LastMessage.Text) <> "EXIT"
state.set("Local.status", "active")
assert state.eval('=Local.status <> "done"') is True
assert state.eval('=Local.status <> "active"') is False
class TestPowerFxArithmetic:
"""Test arithmetic operations."""
@pytest.fixture
def mock_state(self):
"""Create a mock shared state."""
mock_state = MagicMock()
mock_state._data = {}
def mock_get(key, default=None):
return mock_state._data.get(key, default)
def mock_set(key, value):
mock_state._data[key] = value
mock_state.get = MagicMock(side_effect=mock_get)
mock_state.set = MagicMock(side_effect=mock_set)
return mock_state
async def test_addition(self, mock_state):
"""Test + operator."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: value: =Local.TurnCount + 1
state.set("Local.TurnCount", 3)
result = state.eval("=Local.TurnCount + 1")
assert result == 4
class TestPowerFxCustomFunctions:
"""Test custom functions (UserMessage, MessageText, AgentMessage)."""
@pytest.fixture
def mock_state(self):
"""Create a mock shared state."""
mock_state = MagicMock()
mock_state._data = {}
def mock_get(key, default=None):
return mock_state._data.get(key, default)
def mock_set(key, value):
mock_state._data[key] = value
mock_state.get = MagicMock(side_effect=mock_get)
mock_state.set = MagicMock(side_effect=mock_set)
return mock_state
@pytest.mark.asyncio
async def test_agent_message_function(self, mock_state):
"""Test AgentMessage function (.NET compatibility alias for AssistantMessage)."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From .NET YAML: messages: =AgentMessage(Local.Response)
state.set("Local.Response", "Here is the analysis result")
result = state.eval("=AgentMessage(Local.Response)")
assert isinstance(result, dict)
assert result["role"] == "assistant"
assert result["text"] == "Here is the analysis result"
@pytest.mark.asyncio
async def test_agent_message_with_empty_string(self, mock_state):
"""Test AgentMessage with empty string."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
state.set("Local.Response", "")
result = state.eval("=AgentMessage(Local.Response)")
assert result["role"] == "assistant"
assert result["text"] == ""
@pytest.mark.asyncio
async def test_user_message_with_variable(self, mock_state):
"""Test UserMessage function with variable reference."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: messages: =UserMessage(Local.ServiceParameters.IssueDescription)
state.set("Local.ServiceParameters", {"IssueDescription": "My computer won't boot"})
result = state.eval("=UserMessage(Local.ServiceParameters.IssueDescription)")
assert isinstance(result, dict)
assert result["role"] == "user"
assert result["text"] == "My computer won't boot"
async def test_user_message_with_simple_variable(self, mock_state):
"""Test UserMessage with simple variable."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: messages: =Local.Problem
state.set("Local.Problem", "What is 2+2?")
result = state.eval("=UserMessage(Local.Problem)")
assert result["role"] == "user"
assert result["text"] == "What is 2+2?"
async def test_message_text_with_list(self, mock_state):
"""Test MessageText extracts text from message list."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
state.set(
"Local.messages",
[
{"role": "user", "text": "Hello"},
{"role": "assistant", "text": "Hi there!"},
],
)
result = state.eval("=MessageText(Local.messages)")
assert result == "Hi there!"
async def test_message_text_empty_list(self, mock_state):
"""Test MessageText with empty list returns empty string."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
state.set("Local.messages", [])
result = state.eval("=MessageText(Local.messages)")
assert result == ""
class TestPowerFxNestedVariables:
"""Test nested variable access patterns from YAML."""
@pytest.fixture
def mock_state(self):
"""Create a mock shared state."""
mock_state = MagicMock()
mock_state._data = {}
def mock_get(key, default=None):
return mock_state._data.get(key, default)
def mock_set(key, value):
mock_state._data[key] = value
mock_state.get = MagicMock(side_effect=mock_get)
mock_state.set = MagicMock(side_effect=mock_set)
return mock_state
async def test_nested_local_variable(self, mock_state):
"""Test nested Local.* variable access."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: =Local.ServiceParameters.IssueDescription
state.set("Local.ServiceParameters", {"IssueDescription": "Screen is black"})
result = state.eval("=Local.ServiceParameters.IssueDescription")
assert result == "Screen is black"
async def test_nested_routing_parameters(self, mock_state):
"""Test RoutingParameters access."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: =Local.RoutingParameters.TeamName
state.set("Local.RoutingParameters", {"TeamName": "Windows Support"})
result = state.eval("=Local.RoutingParameters.TeamName")
assert result == "Windows Support"
async def test_nested_ticket_parameters(self, mock_state):
"""Test TicketParameters access."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: =Local.TicketParameters.TicketId
state.set("Local.TicketParameters", {"TicketId": "TKT-12345"})
result = state.eval("=Local.TicketParameters.TicketId")
assert result == "TKT-12345"
class TestPowerFxUndefinedVariables:
"""Test graceful handling of undefined variables."""
@pytest.fixture
def mock_state(self):
"""Create a mock shared state."""
mock_state = MagicMock()
mock_state._data = {}
def mock_get(key, default=None):
return mock_state._data.get(key, default)
def mock_set(key, value):
mock_state._data[key] = value
mock_state.get = MagicMock(side_effect=mock_get)
mock_state.set = MagicMock(side_effect=mock_set)
return mock_state
async def test_undefined_local_variable_returns_none(self, mock_state):
"""Test that undefined Local.* variables return None."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# Variable not set - should return None (not raise)
result = state.eval("=Local.UndefinedVariable")
assert result is None
async def test_undefined_nested_variable_returns_none(self, mock_state):
"""Test that undefined nested variables return None."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# Nested undefined variable
result = state.eval("=Local.Something.Nested.Deep")
assert result is None
async def test_undefined_variable_returns_none_with_non_english_ui_culture(self, mock_state):
"""Test that undefined variables return None even when locale is non-English.
Regression test for #4321: on non-English systems, locale settings can cause
PowerFx to emit localized error messages that don't match the English
string guards ("isn't recognized", "Name isn't valid"), crashing the workflow.
The fix evaluates with locale='en-US' and restores the ambient LC_NUMERIC.
"""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# Simulate a non-English locale (e.g. Italian)
original_numeric_locale = locale.setlocale(locale.LC_NUMERIC)
test_numeric_locale: str | None = None
try:
for locale_candidate in ("it_IT.UTF-8", "it_IT", "fr_FR.UTF-8", "fr_FR", "de_DE.UTF-8", "de_DE"):
try:
locale.setlocale(locale.LC_NUMERIC, locale_candidate)
test_numeric_locale = locale.setlocale(locale.LC_NUMERIC)
break
except locale.Error:
continue
if test_numeric_locale is None:
pytest.skip("No non-English LC_NUMERIC locale available on this system")
# Should return None, not raise ValueError with Italian error text
result = state.eval("=Local.StatusConversationId")
assert result is None
# Verify the production code restored LC_NUMERIC after eval
assert locale.setlocale(locale.LC_NUMERIC) == test_numeric_locale
finally:
locale.setlocale(locale.LC_NUMERIC, original_numeric_locale)
class TestStringInterpolation:
"""Test string interpolation patterns."""
@pytest.fixture
def mock_state(self):
"""Create a mock shared state."""
mock_state = MagicMock()
mock_state._data = {}
def mock_get(key, default=None):
return mock_state._data.get(key, default)
def mock_set(key, value):
mock_state._data[key] = value
mock_state.get = MagicMock(side_effect=mock_get)
mock_state.set = MagicMock(side_effect=mock_set)
return mock_state
async def test_interpolate_local_variable(self, mock_state):
"""Test {Local.Variable} interpolation."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: activity: "Created ticket #{Local.TicketParameters.TicketId}"
state.set("Local.TicketParameters", {"TicketId": "TKT-999"})
result = state.interpolate_string("Created ticket #{Local.TicketParameters.TicketId}")
assert result == "Created ticket #TKT-999"
async def test_interpolate_routing_team(self, mock_state):
"""Test routing team interpolation."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
# From YAML: activity: Routing to {Local.RoutingParameters.TeamName}
state.set("Local.RoutingParameters", {"TeamName": "Linux Support"})
result = state.interpolate_string("Routing to {Local.RoutingParameters.TeamName}")
assert result == "Routing to Linux Support"
class TestWorkflowInputsAccess:
"""Test Workflow.Inputs access patterns."""
@pytest.fixture
def mock_state(self):
"""Create a mock shared state."""
mock_state = MagicMock()
mock_state._data = {}
def mock_get(key, default=None):
return mock_state._data.get(key, default)
def mock_set(key, value):
mock_state._data[key] = value
mock_state.get = MagicMock(side_effect=mock_get)
mock_state.set = MagicMock(side_effect=mock_set)
return mock_state
async def test_inputs_name(self, mock_state):
"""Test inputs.name access."""
state = DeclarativeWorkflowState(mock_state)
state.initialize({"name": "Alice", "age": 25})
# .NET style (standard)
result = state.eval("=Workflow.Inputs.name")
assert result == "Alice"
# Also test inputs.name shorthand
result = state.eval("=inputs.name")
assert result == "Alice"
async def test_inputs_problem(self, mock_state):
"""Test inputs.problem access."""
state = DeclarativeWorkflowState(mock_state)
state.initialize({"problem": "What is 5 * 6?"})
# .NET style (standard)
result = state.eval("=Workflow.Inputs.problem")
assert result == "What is 5 * 6?"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,236 @@
# Copyright (c) Microsoft. All rights reserved.
"""Integration tests for workflow samples.
These tests verify that the workflow samples from declarative-agents/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 / "declarative-agents" / "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_executors_exist_for_sample_actions(self, all_action_kinds):
"""Test that executors exist for all action kinds used in samples."""
from agent_framework_declarative._workflows._declarative_builder import ALL_ACTION_EXECUTORS
registered_executors = set(ALL_ACTION_EXECUTORS.keys())
# Kinds handled structurally by the builder (not registered as executors)
structural_kinds = {
"OnConversationStart", # Trigger kind, not an action
"ConditionGroup", # Decomposed into evaluator/join nodes
"GotoAction", # Resolved as graph edges, not executor nodes
}
missing_executors = all_action_kinds - registered_executors - structural_kinds
assert not missing_executors, (
f"Missing executors for action kinds used in workflow samples: {sorted(missing_executors)}"
)
@@ -0,0 +1,584 @@
# 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.local == {}
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("Local.counter", 10)
assert state.get("Local.counter") == 10
def test_set_and_get_nested_turn_variable(self):
"""Test setting and getting a nested turn variable."""
state = WorkflowState()
state.set("Local.data.nested.value", "test")
assert state.get("Local.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("Local.nonexistent") is None
assert state.get("Local.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("Local.results", "item1")
assert state.get("Local.results") == ["item1"]
def test_append_to_existing_list(self):
"""Test appending to an existing list."""
state = WorkflowState()
state.set("Local.results", ["item1"])
state.append("Local.results", "item2")
assert state.get("Local.results") == ["item1", "item2"]
def test_append_to_non_list_raises(self):
"""Test that appending to a non-list raises ValueError."""
state = WorkflowState()
state.set("Local.value", "not a list")
with pytest.raises(ValueError, match="Cannot append to non-list"):
state.append("Local.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("Local.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["Local"]["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("Local.counter", 5)
cloned = state.clone()
assert cloned.get("Workflow.Inputs.query") == "test"
assert cloned.get("Local.counter") == 5
def test_clone_is_independent(self):
"""Test that modifications to clone don't affect original."""
state = WorkflowState()
state.set("Local.value", "original")
cloned = state.clone()
cloned.set("Local.value", "modified")
assert state.get("Local.value") == "original"
assert cloned.get("Local.value") == "modified"
class TestWorkflowStateResetTurn:
"""Tests for turn reset."""
def test_reset_local_clears_turn_variables(self):
"""Test that reset_local clears turn variables."""
state = WorkflowState()
state.set("Local.var1", "value1")
state.set("Local.var2", "value2")
state.reset_local()
assert state.get("Local.var1") is None
assert state.get("Local.var2") is None
assert state.local == {}
def test_reset_local_preserves_other_state(self):
"""Test that reset_local preserves other state."""
state = WorkflowState(inputs={"query": "test"})
state.set("Workflow.Outputs.result", "done")
state.set("Local.temp", "will be cleared")
state.reset_local()
assert state.get("Workflow.Inputs.query") == "test"
assert state.get("Workflow.Outputs.result") == "done"
class TestWorkflowStateEvalSimple:
"""Tests for _eval_simple fallback PowerFx evaluation."""
def test_negation_prefix(self):
"""Test negation with ! prefix."""
state = WorkflowState()
state.set("Local.value", True)
assert state._eval_simple("!Local.value") is False
state.set("Local.value", False)
assert state._eval_simple("!Local.value") is True
def test_not_function(self):
"""Test Not() function."""
state = WorkflowState()
state.set("Local.flag", True)
assert state._eval_simple("Not(Local.flag)") is False
state.set("Local.flag", False)
assert state._eval_simple("Not(Local.flag)") is True
def test_and_operator(self):
"""Test And operator."""
state = WorkflowState()
state.set("Local.a", True)
state.set("Local.b", True)
assert state._eval_simple("Local.a And Local.b") is True
state.set("Local.b", False)
assert state._eval_simple("Local.a And Local.b") is False
def test_or_operator(self):
"""Test Or operator."""
state = WorkflowState()
state.set("Local.a", False)
state.set("Local.b", False)
assert state._eval_simple("Local.a Or Local.b") is False
state.set("Local.b", True)
assert state._eval_simple("Local.a Or Local.b") is True
def test_or_operator_double_pipe(self):
"""Test || operator."""
state = WorkflowState()
state.set("Local.x", False)
state.set("Local.y", True)
assert state._eval_simple("Local.x || Local.y") is True
def test_less_than(self):
"""Test < comparison."""
state = WorkflowState()
state.set("Local.num", 5)
assert state._eval_simple("Local.num < 10") is True
assert state._eval_simple("Local.num < 3") is False
def test_greater_than(self):
"""Test > comparison."""
state = WorkflowState()
state.set("Local.num", 5)
assert state._eval_simple("Local.num > 3") is True
assert state._eval_simple("Local.num > 10") is False
def test_less_than_or_equal(self):
"""Test <= comparison."""
state = WorkflowState()
state.set("Local.num", 5)
assert state._eval_simple("Local.num <= 5") is True
assert state._eval_simple("Local.num <= 4") is False
def test_greater_than_or_equal(self):
"""Test >= comparison."""
state = WorkflowState()
state.set("Local.num", 5)
assert state._eval_simple("Local.num >= 5") is True
assert state._eval_simple("Local.num >= 6") is False
def test_not_equal(self):
"""Test <> comparison."""
state = WorkflowState()
state.set("Local.val", "hello")
assert state._eval_simple('Local.val <> "world"') is True
assert state._eval_simple('Local.val <> "hello"') is False
def test_equal(self):
"""Test = comparison."""
state = WorkflowState()
state.set("Local.val", "test")
assert state._eval_simple('Local.val = "test"') is True
assert state._eval_simple('Local.val = "other"') is False
def test_addition_numeric(self):
"""Test + operator with numbers."""
state = WorkflowState()
state.set("Local.a", 3)
state.set("Local.b", 4)
assert state._eval_simple("Local.a + Local.b") == 7.0
def test_addition_string_concat(self):
"""Test + operator falls back to string concat."""
state = WorkflowState()
state.set("Local.a", "hello")
state.set("Local.b", "world")
assert state._eval_simple("Local.a + Local.b") == "helloworld"
def test_addition_with_none(self):
"""Test + treats None as 0."""
state = WorkflowState()
state.set("Local.a", 5)
# Local.b doesn't exist, so it's None
assert state._eval_simple("Local.a + Local.b") == 5.0
def test_subtraction(self):
"""Test - operator."""
state = WorkflowState()
state.set("Local.a", 10)
state.set("Local.b", 3)
assert state._eval_simple("Local.a - Local.b") == 7.0
def test_subtraction_with_none(self):
"""Test - treats None as 0."""
state = WorkflowState()
state.set("Local.a", 5)
assert state._eval_simple("Local.a - Local.missing") == 5.0
def test_multiplication(self):
"""Test * operator."""
state = WorkflowState()
state.set("Local.a", 4)
state.set("Local.b", 5)
assert state._eval_simple("Local.a * Local.b") == 20.0
def test_multiplication_with_none(self):
"""Test * treats None as 0."""
state = WorkflowState()
state.set("Local.a", 5)
assert state._eval_simple("Local.a * Local.missing") == 0.0
def test_division(self):
"""Test / operator."""
state = WorkflowState()
state.set("Local.a", 20)
state.set("Local.b", 4)
assert state._eval_simple("Local.a / Local.b") == 5.0
def test_division_by_zero(self):
"""Test / by zero returns None."""
state = WorkflowState()
state.set("Local.a", 10)
state.set("Local.b", 0)
assert state._eval_simple("Local.a / Local.b") is None
def test_string_literal_double_quotes(self):
"""Test string literal with double quotes."""
state = WorkflowState()
assert state._eval_simple('"hello world"') == "hello world"
def test_string_literal_single_quotes(self):
"""Test string literal with single quotes."""
state = WorkflowState()
assert state._eval_simple("'hello world'") == "hello world"
def test_integer_literal(self):
"""Test integer literal."""
state = WorkflowState()
assert state._eval_simple("42") == 42
def test_float_literal(self):
"""Test float literal."""
state = WorkflowState()
assert state._eval_simple("3.14") == 3.14
def test_boolean_true_literal(self):
"""Test true literal (case insensitive)."""
state = WorkflowState()
assert state._eval_simple("true") is True
assert state._eval_simple("True") is True
assert state._eval_simple("TRUE") is True
def test_boolean_false_literal(self):
"""Test false literal (case insensitive)."""
state = WorkflowState()
assert state._eval_simple("false") is False
assert state._eval_simple("False") is False
assert state._eval_simple("FALSE") is False
def test_variable_reference(self):
"""Test simple variable reference."""
state = WorkflowState()
state.set("Local.myvar", "myvalue")
assert state._eval_simple("Local.myvar") == "myvalue"
def test_unknown_expression_returned_as_is(self):
"""Test that unknown expressions are returned as-is."""
state = WorkflowState()
result = state._eval_simple("unknown_identifier")
assert result == "unknown_identifier"
def test_agent_namespace_reference(self):
"""Test Agent namespace variable reference."""
state = WorkflowState()
state.set_agent_result(text="agent response")
assert state._eval_simple("Agent.text") == "agent response"
def test_conversation_namespace_reference(self):
"""Test Conversation namespace variable reference."""
state = WorkflowState()
state.add_conversation_message({"role": "user", "content": "hello"})
result = state._eval_simple("Conversation.messages")
assert len(result) == 1
def test_workflow_inputs_reference(self):
"""Test Workflow.Inputs reference."""
state = WorkflowState(inputs={"name": "test"})
assert state._eval_simple("Workflow.Inputs.name") == "test"
class TestWorkflowStateParseFunctionArgs:
"""Tests for _parse_function_args helper."""
def test_simple_args(self):
"""Test parsing simple comma-separated args."""
state = WorkflowState()
args = state._parse_function_args("1, 2, 3")
assert args == ["1", "2", "3"]
def test_string_args_with_commas(self):
"""Test parsing string args containing commas."""
state = WorkflowState()
args = state._parse_function_args('"hello, world", "another"')
assert args == ['"hello, world"', '"another"']
def test_nested_function_args(self):
"""Test parsing nested function calls."""
state = WorkflowState()
args = state._parse_function_args("Concat(a, b), c")
assert args == ["Concat(a, b)", "c"]
def test_empty_args(self):
"""Test parsing empty args string."""
state = WorkflowState()
args = state._parse_function_args("")
assert args == []
def test_single_arg(self):
"""Test parsing single argument."""
state = WorkflowState()
args = state._parse_function_args("single")
assert args == ["single"]
def test_deeply_nested_parens(self):
"""Test parsing deeply nested parentheses."""
state = WorkflowState()
args = state._parse_function_args("Func1(Func2(a, b)), c")
assert args == ["Func1(Func2(a, b))", "c"]
class TestWorkflowStateEvalIfExpression:
"""Tests for eval_if_expression method."""
def test_dict_values_evaluated(self):
"""Test that dict values are recursively evaluated."""
state = WorkflowState()
state.set("Local.name", "World")
result = state.eval_if_expression({"greeting": "=Local.name", "static": "value"})
assert result == {"greeting": "World", "static": "value"}
def test_list_values_evaluated(self):
"""Test that list values are recursively evaluated."""
state = WorkflowState()
state.set("Local.val", 42)
result = state.eval_if_expression(["=Local.val", "static"])
assert result == [42, "static"]
def test_nested_dict_in_list(self):
"""Test nested dict in list is evaluated."""
state = WorkflowState()
state.set("Local.x", 10)
result = state.eval_if_expression([{"key": "=Local.x"}])
assert result == [{"key": 10}]
class TestWorkflowStateSetErrors:
"""Tests for set() error handling."""
def test_set_workflow_directly_raises(self):
"""Test that setting Workflow directly raises error."""
state = WorkflowState()
with pytest.raises(ValueError, match="Cannot set 'Workflow' directly"):
state.set("Workflow", "value")
def test_set_unknown_workflow_namespace_raises(self):
"""Test that setting unknown Workflow sub-namespace raises."""
state = WorkflowState()
with pytest.raises(ValueError, match="Unknown Workflow namespace"):
state.set("Workflow.Unknown.path", "value")
def test_set_namespace_root_raises(self):
"""Test that setting namespace root raises error."""
state = WorkflowState()
with pytest.raises(ValueError, match="Cannot replace entire namespace"):
state.set("Local", "value")
class TestWorkflowStateGetEdgeCases:
"""Tests for get() edge cases."""
def test_get_empty_path(self):
"""Test get with empty path returns default."""
state = WorkflowState()
assert state.get("", "default") == "default"
def test_get_unknown_namespace(self):
"""Test get from unknown namespace returns default."""
state = WorkflowState()
assert state.get("Unknown.path") is None
assert state.get("Unknown.path", "fallback") == "fallback"
def test_get_with_object_attribute(self):
"""Test get navigates object attributes."""
state = WorkflowState()
class MockObj:
attr = "attribute_value"
state.set("Local.obj", MockObj())
assert state.get("Local.obj.attr") == "attribute_value"
def test_get_unknown_workflow_subspace(self):
"""Test get from unknown Workflow sub-namespace."""
state = WorkflowState()
assert state.get("Workflow.Unknown.path") is None
class TestWorkflowStateConversationIdInit:
"""Tests that WorkflowState generates a real UUID for System.ConversationId."""
def test_conversation_id_is_not_default(self):
"""System.ConversationId should be a UUID, not 'default'."""
import uuid
state = WorkflowState()
conv_id = state.get("System.ConversationId")
assert conv_id is not None
assert conv_id != "default"
uuid.UUID(conv_id) # Raises ValueError if not a valid UUID
def test_conversations_dict_initialized(self):
"""System.conversations should contain an entry matching ConversationId."""
state = WorkflowState()
conv_id = state.get("System.ConversationId")
conversations = state.get("System.conversations")
assert conversations is not None
assert conv_id in conversations
assert conversations[conv_id]["id"] == conv_id
assert conversations[conv_id]["messages"] == []
def test_each_instance_generates_unique_id(self):
"""Each WorkflowState instance should have a different ConversationId."""
state1 = WorkflowState()
state2 = WorkflowState()
assert state1.get("System.ConversationId") != state2.get("System.ConversationId")
@@ -0,0 +1,29 @@
#
# Integration fixture: end-to-end HttpRequestAction round-trip using a
# stub HttpRequestHandler. Mirrors the .NET integration fixture in
# dotnet/tests/.../Workflows/HttpRequest.yaml.
#
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_http_request_test
actions:
# Set the repo owner used to form the request URL.
- kind: SetVariable
id: set_repo_owner
variable: Local.RepoOwner
value: dotnet
# Invoke the (stubbed) GitHub repo API.
- kind: HttpRequestAction
id: fetch_repo_info
conversationId: =System.ConversationId
method: GET
url: =Concatenate("https://api.github.com/repos/", Local.RepoOwner, "/runtime")
headers:
Accept: application/vnd.github+json
User-Agent: agent-framework-integration-test
response: Local.RepoInfo
responseHeaders: Local.RepoHeaders