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
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:
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
+1226
File diff suppressed because it is too large
Load Diff
+1057
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
+1025
File diff suppressed because it is too large
Load Diff
+574
@@ -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,
|
||||
}
|
||||
+461
@@ -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,
|
||||
}
|
||||
+243
@@ -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,
|
||||
}
|
||||
+417
@@ -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,
|
||||
}
|
||||
+660
@@ -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)
|
||||
+498
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user