chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
@@ -0,0 +1,71 @@
# Copyright (c) Microsoft. All rights reserved.
"""Declarative specification support for Microsoft Agent Framework.
Release stage:
* The declarative-workflows surface (``WorkflowFactory``, executors, handlers,
etc.) is at release-candidate stability.
* The declarative-agents surface (``AgentFactory`` and the YAML agent
loading/parsing path: ``DeclarativeLoaderError``, ``ProviderLookupError``,
``ProviderTypeMapping``) is *experimental* and may change or be removed in
future versions without notice. Using these symbols emits an
``ExperimentalWarning`` on first use.
"""
from importlib import metadata
from ._loader import AgentFactory, DeclarativeLoaderError, ProviderLookupError, ProviderTypeMapping
from ._workflows import (
AgentExternalInputRequest,
AgentExternalInputResponse,
DeclarativeActionError,
DeclarativeWorkflowError,
DefaultHttpRequestHandler,
DefaultMCPToolHandler,
ExternalInputRequest,
ExternalInputResponse,
HttpRequestHandler,
HttpRequestInfo,
HttpRequestResult,
MCPToolApprovalRequest,
MCPToolHandler,
MCPToolInvocation,
MCPToolResult,
ToolApprovalRequest,
ToolApprovalResponse,
WorkflowFactory,
WorkflowState,
)
try:
__version__ = metadata.version(__name__)
except metadata.PackageNotFoundError:
__version__ = "0.0.0" # Fallback for development mode
__all__ = [
"AgentExternalInputRequest",
"AgentExternalInputResponse",
"AgentFactory",
"DeclarativeActionError",
"DeclarativeLoaderError",
"DeclarativeWorkflowError",
"DefaultHttpRequestHandler",
"DefaultMCPToolHandler",
"ExternalInputRequest",
"ExternalInputResponse",
"HttpRequestHandler",
"HttpRequestInfo",
"HttpRequestResult",
"MCPToolApprovalRequest",
"MCPToolHandler",
"MCPToolInvocation",
"MCPToolResult",
"ProviderLookupError",
"ProviderTypeMapping",
"ToolApprovalRequest",
"ToolApprovalResponse",
"WorkflowFactory",
"WorkflowState",
"__version__",
]
@@ -0,0 +1,868 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import sys
from collections.abc import Callable, Mapping
from pathlib import Path
from typing import Any, cast
import yaml
from agent_framework import (
Agent,
SupportsChatGetResponse,
)
from agent_framework import (
FunctionTool as AFFunctionTool,
)
from agent_framework._feature_stage import (
ExperimentalFeature,
experimental,
)
from agent_framework.exceptions import AgentException
from dotenv import load_dotenv
from ._models import (
AnonymousConnection,
ApiKeyConnection,
CodeInterpreterTool,
FileSearchTool,
FunctionTool,
McpServerToolSpecifyApprovalMode,
McpTool,
Model,
ModelOptions,
PromptAgent,
ReferenceConnection,
RemoteConnection,
Tool,
WebSearchTool,
_safe_mode_context, # type: ignore[reportPrivateUsage]
agent_schema_dispatch,
)
if sys.version_info >= (3, 11):
from typing import TypedDict # pragma: no cover
else:
from typing_extensions import TypedDict # pragma: no cover
@experimental(feature_id=ExperimentalFeature.DECLARATIVE_AGENTS)
class ProviderTypeMapping(TypedDict, total=True):
package: str
name: str
model_field: str
endpoint_field: str | None
api_key_field: str | None
PROVIDER_TYPE_OBJECT_MAPPING: dict[str, ProviderTypeMapping] = {
"AzureOpenAI": {
"package": "agent_framework.openai",
"name": "OpenAIChatClient",
"model_field": "model",
"endpoint_field": "azure_endpoint",
"api_key_field": "api_key",
},
"AzureOpenAI.Chat": {
"package": "agent_framework.openai",
"name": "OpenAIChatCompletionClient",
"model_field": "model",
"endpoint_field": "azure_endpoint",
"api_key_field": "api_key",
},
"AzureOpenAI.Responses": {
"package": "agent_framework.openai",
"name": "OpenAIChatClient",
"model_field": "model",
"endpoint_field": "azure_endpoint",
"api_key_field": "api_key",
},
"Foundry": {
"package": "agent_framework.foundry",
"name": "FoundryChatClient",
"model_field": "model",
"endpoint_field": "project_endpoint",
"api_key_field": None,
},
"OpenAI.Chat": {
"package": "agent_framework.openai",
"name": "OpenAIChatCompletionClient",
"model_field": "model",
"endpoint_field": "base_url",
"api_key_field": "api_key",
},
"OpenAI.Responses": {
"package": "agent_framework.openai",
"name": "OpenAIChatClient",
"model_field": "model",
"endpoint_field": "base_url",
"api_key_field": "api_key",
},
"OpenAI": {
"package": "agent_framework.openai",
"name": "OpenAIChatClient",
"model_field": "model",
"endpoint_field": "base_url",
"api_key_field": "api_key",
},
"Foundry.Chat": {
"package": "agent_framework.foundry",
"name": "FoundryChatClient",
"model_field": "model",
"endpoint_field": "project_endpoint",
"api_key_field": None,
},
"Anthropic.Chat": {
"package": "agent_framework.anthropic",
"name": "AnthropicChatClient",
"model_field": "model",
"endpoint_field": None,
"api_key_field": "api_key",
},
}
@experimental(feature_id=ExperimentalFeature.DECLARATIVE_AGENTS)
class DeclarativeLoaderError(AgentException):
"""Exception raised for errors in the declarative loader."""
pass
@experimental(feature_id=ExperimentalFeature.DECLARATIVE_AGENTS)
class ProviderLookupError(DeclarativeLoaderError):
"""Exception raised for errors in provider type lookup."""
pass
@experimental(feature_id=ExperimentalFeature.DECLARATIVE_AGENTS)
class AgentFactory:
"""Factory for creating Agent instances from declarative YAML definitions.
AgentFactory parses YAML agent definitions (PromptAgent kind) and creates
configured Agent instances with the appropriate chat client, tools,
and response format.
Examples:
.. code-block:: python
from agent_framework_declarative import AgentFactory
# Create agent from YAML file
factory = AgentFactory()
agent = factory.create_agent_from_yaml_path("agent.yaml")
# Run the agent
async for event in agent.run("Hello!", stream=True):
print(event)
.. code-block:: python
from agent_framework.openai import OpenAIChatClient
from agent_framework_declarative import AgentFactory
# With pre-configured chat client
client = OpenAIChatClient()
factory = AgentFactory(client=client)
agent = factory.create_agent_from_yaml_path("agent.yaml")
.. code-block:: python
from agent_framework_declarative import AgentFactory
# From inline YAML string
yaml_content = '''
kind: Prompt
name: GreetingAgent
instructions: You are a friendly assistant.
model:
id: gpt-4o
provider: AzureOpenAI
'''
factory = AgentFactory()
agent = factory.create_agent_from_yaml(yaml_content)
"""
def __init__(
self,
*,
client: SupportsChatGetResponse | None = None,
bindings: Mapping[str, Any] | None = None,
connections: Mapping[str, Any] | None = None,
client_kwargs: Mapping[str, Any] | None = None,
additional_mappings: Mapping[str, ProviderTypeMapping] | None = None,
default_provider: str = "Foundry",
safe_mode: bool = True,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
) -> None:
"""Create the agent factory.
Args:
client: An optional SupportsChatGetResponse instance to use as a dependency.
This will be passed to the Agent that gets created.
If you need to create multiple agents with different chat clients,
do not pass this and instead provide the chat client in the YAML definition.
bindings: An optional dictionary of bindings to use when creating agents.
connections: An optional dictionary of connections to resolve ReferenceConnections.
client_kwargs: An optional dictionary of keyword arguments to pass to chat client constructor.
additional_mappings: An optional dictionary to extend the provider type to object mapping.
Should have the structure:
..code-block:: python
additional_mappings = {
"Provider.ApiType": {
"package": "package.name",
"name": "ClassName",
"model_field": "field_name_in_constructor",
"endpoint_field": "endpoint_kwarg_name_or_null",
"api_key_field": "api_key_kwarg_name_or_null",
},
...
}
Here, "Provider.ApiType" is the lookup key used when both provider and apiType are specified in the
model, "Provider" is also allowed.
Package refers to which model needs to be imported, Name is the class name of the
SupportsChatGetResponse implementation, and model_field is the name of the field in the
constructor that accepts the model.id value.
default_provider: The default provider used when model.provider is not specified,
default is "Foundry", which uses the FoundryChatClient.
safe_mode: Whether to run in safe mode, default is True.
When safe_mode is True, environment variables are not accessible in the powerfx expressions.
You can still use environment variables, but through the constructors of the classes.
Which means you must make sure you are using the standard env variable names of the classes
you are using and not custom ones and remove the powerfx statements that start with `=Env.`.
Only when you trust the source of your yaml files, you can set safe_mode to False
via the AgentFactory constructor.
env_file_path: The path to the .env file to load environment variables from.
env_file_encoding: The encoding of the .env file, defaults to 'utf-8'.
Examples:
.. code-block:: python
from agent_framework_declarative import AgentFactory
# Minimal initialization
factory = AgentFactory()
.. code-block:: python
from agent_framework.openai import OpenAIChatClient
from agent_framework_declarative import AgentFactory
# With shared chat client
client = OpenAIChatClient()
factory = AgentFactory(
client=client,
env_file_path=".env",
)
.. code-block:: python
from agent_framework_declarative import AgentFactory
# With custom provider mappings
factory = AgentFactory(
additional_mappings={
"CustomProvider.Chat": {
"package": "my_package.clients",
"name": "CustomChatClient",
"model_field": "model",
},
},
)
"""
self.client = client
self.bindings = bindings
self.connections = connections
self.client_kwargs = client_kwargs or {}
self.additional_mappings = additional_mappings or {}
self.default_provider: str = default_provider
self.safe_mode = safe_mode
load_dotenv(dotenv_path=env_file_path, encoding=env_file_encoding)
def create_agent_from_yaml_path(self, yaml_path: str | Path) -> Agent:
"""Create a Agent from a YAML file path.
This method does the following things:
1. Loads the YAML file into an AgentSchema object.
2. Validates that the loaded object is a PromptAgent.
3. Creates the appropriate ChatClient based on the model provider and apiType.
4. Parses the tools, options, and response format from the PromptAgent.
5. Creates and returns a Agent instance with the configured properties.
Args:
yaml_path: Path to the YAML file representation of a PromptAgent.
Returns:
The ``Agent`` instance created from the YAML file.
Raises:
DeclarativeLoaderError: If the YAML does not represent a PromptAgent.
ProviderLookupError: If the provider type is unknown or unsupported.
ValueError: If a ReferenceConnection cannot be resolved.
ModuleNotFoundError: If the required module for the provider type cannot be imported.
AttributeError: If the required class for the provider type cannot be found in the module.
Examples:
.. code-block:: python
from agent_framework_declarative import AgentFactory
factory = AgentFactory()
agent = factory.create_agent_from_yaml_path("agents/support_agent.yaml")
# Execute the agent
async for event in agent.run("Help me with my order", stream=True):
print(event)
.. code-block:: python
from pathlib import Path
from agent_framework_declarative import AgentFactory
# Using Path object for cross-platform compatibility
agent_path = Path(__file__).parent / "agents" / "writer.yaml"
factory = AgentFactory()
agent = factory.create_agent_from_yaml_path(agent_path)
"""
if not isinstance(yaml_path, Path):
yaml_path = Path(yaml_path)
if not yaml_path.exists():
raise DeclarativeLoaderError(f"YAML file not found at path: {yaml_path}")
with open(yaml_path) as f:
yaml_str = f.read()
return self.create_agent_from_yaml(yaml_str)
def create_agent_from_yaml(self, yaml_str: str) -> Agent:
"""Create a Agent from a YAML string.
This method does the following things:
1. Loads the YAML string into an AgentSchema object.
2. Validates that the loaded object is a PromptAgent.
3. Creates the appropriate ChatClient based on the model provider and apiType.
4. Parses the tools, options, and response format from the PromptAgent.
5. Creates and returns a Agent instance with the configured properties.
Args:
yaml_str: YAML string representation of a PromptAgent.
Returns:
The ``Agent`` instance created from the YAML string.
Raises:
DeclarativeLoaderError: If the YAML does not represent a PromptAgent.
ProviderLookupError: If the provider type is unknown or unsupported.
ValueError: If a ReferenceConnection cannot be resolved.
ModuleNotFoundError: If the required module for the provider type cannot be imported.
AttributeError: If the required class for the provider type cannot be found in the module.
Examples:
.. code-block:: python
from agent_framework_declarative import AgentFactory
yaml_content = '''
kind: Prompt
name: TranslationAgent
description: Translates text between languages
instructions: |
You are a translation assistant.
Translate user input to the requested language.
model:
id: gpt-4o
provider: AzureOpenAI
options:
temperature: 0.3
'''
factory = AgentFactory()
agent = factory.create_agent_from_yaml(yaml_content)
.. code-block:: python
from agent_framework_declarative import AgentFactory
from pydantic import BaseModel
# Agent with structured output
yaml_content = '''
kind: Prompt
name: SentimentAnalyzer
instructions: Analyze the sentiment of the input text.
model:
id: gpt-4o
outputSchema:
type: object
properties:
sentiment:
type: string
enum: [positive, negative, neutral]
confidence:
type: number
'''
factory = AgentFactory()
agent = factory.create_agent_from_yaml(yaml_content)
"""
return self.create_agent_from_dict(yaml.safe_load(yaml_str))
def create_agent_from_dict(self, agent_def: dict[str, Any]) -> Agent:
"""Create a Agent from a dictionary definition.
This method does the following things:
1. Converts the dictionary into an AgentSchema object.
2. Validates that the loaded object is a PromptAgent.
3. Creates the appropriate ChatClient based on the model provider and apiType.
4. Parses the tools, options, and response format from the PromptAgent.
5. Creates and returns a Agent instance with the configured properties.
Args:
agent_def: Dictionary representation of a PromptAgent.
Returns:
The `Agent` instance created from the dictionary.
Raises:
DeclarativeLoaderError: If the dictionary does not represent a PromptAgent.
ProviderLookupError: If the provider type is unknown or unsupported.
ValueError: If a ReferenceConnection cannot be resolved.
ModuleNotFoundError: If the required module for the provider type cannot be imported.
AttributeError: If the required class for the provider type cannot be found in the module.
Examples:
.. code-block:: python
from agent_framework_declarative import AgentFactory
agent_def = {
"kind": "Prompt",
"name": "TranslationAgent",
"description": "Translates text between languages",
"instructions": "You are a translation assistant.",
"model": {
"id": "gpt-4o",
"provider": "AzureOpenAI",
},
}
factory = AgentFactory()
agent = factory.create_agent_from_dict(agent_def)
"""
# Set safe_mode context before parsing YAML to control PowerFx environment variable access
_safe_mode_context.set(self.safe_mode)
prompt_agent = agent_schema_dispatch(agent_def)
if not isinstance(prompt_agent, PromptAgent):
raise DeclarativeLoaderError("Only definitions for a PromptAgent are supported for agent creation.")
# Step 1: Create the ChatClient
client = self._get_client(prompt_agent)
# Step 2: Get the chat options
chat_options = self._parse_chat_options(prompt_agent.model)
if tools := self._parse_tools(prompt_agent.tools):
chat_options["tools"] = tools
if output_schema := prompt_agent.outputSchema:
chat_options["response_format"] = output_schema.to_json_schema()
# Step 3: Create the agent instance
return Agent(
client=client,
name=prompt_agent.name,
description=prompt_agent.description,
instructions=prompt_agent.instructions,
default_options=chat_options, # type: ignore[arg-type]
)
async def create_agent_from_yaml_path_async(self, yaml_path: str | Path) -> Agent:
"""Async version: Create a Agent from a YAML file path.
This is the async counterpart to ``create_agent_from_dict`` and is useful when
the rest of your setup is already async.
Args:
yaml_path: Path to the YAML file representation of a PromptAgent.
Returns:
The ``Agent`` instance created from the YAML file.
Examples:
.. code-block:: python
from agent_framework_declarative import AgentFactory
factory = AgentFactory(
client_kwargs={"credential": credential},
default_provider="Foundry",
)
agent = await factory.create_agent_from_yaml_path_async("agent.yaml")
"""
if not isinstance(yaml_path, Path):
yaml_path = Path(yaml_path)
if not yaml_path.exists():
raise DeclarativeLoaderError(f"YAML file not found at path: {yaml_path}")
yaml_str = yaml_path.read_text()
return await self.create_agent_from_yaml_async(yaml_str)
async def create_agent_from_yaml_async(self, yaml_str: str) -> Agent:
"""Async version: Create a Agent from a YAML string.
Use this method when the surrounding call site is already async and you
want to build an agent directly from YAML text.
Args:
yaml_str: YAML string representation of a PromptAgent.
Returns:
The ``Agent`` instance created from the YAML string.
Examples:
.. code-block:: python
from agent_framework_declarative import AgentFactory
yaml_content = '''
kind: Prompt
name: MyAgent
instructions: You are a helpful assistant.
model:
id: gpt-4o
provider: Foundry
'''
factory = AgentFactory(client_kwargs={"credential": credential})
agent = await factory.create_agent_from_yaml_async(yaml_content)
"""
return await self.create_agent_from_dict_async(yaml.safe_load(yaml_str))
async def create_agent_from_dict_async(self, agent_def: dict[str, Any]) -> Agent:
"""Async version: Create a Agent from a dictionary definition.
This is the async counterpart to ``create_agent_from_dict`` and is useful when
the rest of your setup is already async.
Args:
agent_def: Dictionary representation of a PromptAgent.
Returns:
The ``Agent`` instance created from the dictionary.
Examples:
.. code-block:: python
from agent_framework_declarative import AgentFactory
agent_def = {
"kind": "Prompt",
"name": "MyAgent",
"instructions": "You are a helpful assistant.",
"model": {
"id": "gpt-4o",
"provider": "Foundry",
},
}
factory = AgentFactory(client_kwargs={"credential": credential})
agent = await factory.create_agent_from_dict_async(agent_def)
"""
# Set safe_mode context before parsing YAML to control PowerFx environment variable access
_safe_mode_context.set(self.safe_mode)
prompt_agent = agent_schema_dispatch(agent_def)
if not isinstance(prompt_agent, PromptAgent):
raise DeclarativeLoaderError("Only definitions for a PromptAgent are supported for agent creation.")
client = self._get_client(prompt_agent)
chat_options = self._parse_chat_options(prompt_agent.model)
if tools := self._parse_tools(prompt_agent.tools):
chat_options["tools"] = tools
if output_schema := prompt_agent.outputSchema:
chat_options["response_format"] = output_schema.to_json_schema()
return Agent(
client=client,
name=prompt_agent.name,
description=prompt_agent.description,
instructions=prompt_agent.instructions,
default_options=chat_options, # type: ignore[arg-type]
)
async def _create_agent_with_provider(self, prompt_agent: PromptAgent, mapping: ProviderTypeMapping) -> Agent:
"""Create an Agent through a provider object that exposes ``create_agent``.
This remains available as an internal escape hatch for provider-style custom mappings
that return a fully constructed ``Agent`` rather than a chat client.
"""
module_name = mapping["package"]
class_name = mapping["name"]
module = __import__(module_name, fromlist=[class_name])
provider_class = getattr(module, class_name)
provider_kwargs: dict[str, Any] = {}
provider_kwargs.update(self.client_kwargs)
endpoint_field = mapping.get("endpoint_field")
api_key_field = mapping.get("api_key_field", "api_key")
if prompt_agent.model and prompt_agent.model.connection:
match prompt_agent.model.connection:
case ApiKeyConnection():
if api_key_field:
provider_kwargs[api_key_field] = prompt_agent.model.connection.apiKey
if prompt_agent.model.connection.endpoint and endpoint_field:
provider_kwargs[endpoint_field] = prompt_agent.model.connection.endpoint
case RemoteConnection() | AnonymousConnection():
if prompt_agent.model.connection.endpoint and endpoint_field:
provider_kwargs[endpoint_field] = prompt_agent.model.connection.endpoint
case ReferenceConnection():
pass
provider = provider_class(**provider_kwargs)
tools = self._parse_tools(prompt_agent.tools) if prompt_agent.tools else None
default_options: dict[str, Any] | None = None
if prompt_agent.outputSchema:
default_options = {"response_format": prompt_agent.outputSchema.to_json_schema()}
return cast(
Agent,
await provider.create_agent(
name=prompt_agent.name,
model=prompt_agent.model.id if prompt_agent.model else None,
instructions=prompt_agent.instructions,
description=prompt_agent.description,
tools=tools,
default_options=default_options,
),
)
def _get_client(self, prompt_agent: PromptAgent) -> SupportsChatGetResponse:
"""Create the SupportsChatGetResponse instance based on the PromptAgent model."""
if not prompt_agent.model:
# if no model is defined, use the supplied client
if self.client:
return self.client
raise DeclarativeLoaderError(
"ChatClient must be provided to create agent from PromptAgent, "
"alternatively define a model in the PromptAgent."
)
mapping = self._retrieve_provider_configuration(prompt_agent.model)
setup_dict: dict[str, Any] = {}
setup_dict.update(self.client_kwargs)
endpoint_field = mapping.get("endpoint_field")
api_key_field = mapping.get("api_key_field", "api_key")
# parse connections
if prompt_agent.model.connection:
match prompt_agent.model.connection:
case ApiKeyConnection():
if api_key_field:
setup_dict[api_key_field] = prompt_agent.model.connection.apiKey
elif prompt_agent.model.connection.apiKey:
raise DeclarativeLoaderError(
f"{mapping['name']} does not support API key-based model connections."
)
if prompt_agent.model.connection.endpoint:
if not endpoint_field:
raise DeclarativeLoaderError(
f"{mapping['name']} does not support endpoint-based model connections."
)
setup_dict[endpoint_field] = prompt_agent.model.connection.endpoint
case RemoteConnection() | AnonymousConnection():
if prompt_agent.model.connection.endpoint:
if not endpoint_field:
raise DeclarativeLoaderError(
f"{mapping['name']} does not support endpoint-based model connections."
)
setup_dict[endpoint_field] = prompt_agent.model.connection.endpoint
case ReferenceConnection():
if not self.connections:
raise ValueError("Connections must be provided to resolve ReferenceConnection")
# find the referenced connection
if prompt_agent.model.connection.name and (
value := self.connections.get(prompt_agent.model.connection.name)
):
setup_dict[prompt_agent.model.connection.name] = value
else:
raise ValueError(
f"ReferenceConnection with name {prompt_agent.model.connection.name} not found in provided "
"connections."
)
# Any client we create, needs a model.id
if not prompt_agent.model.id:
# if prompt_agent.model is defined, but no id, use the supplied client
if self.client:
return self.client
# or raise, since we cannot create a client without a model
raise DeclarativeLoaderError(
"ChatClient must be provided to create agent from PromptAgent, or define model.id in the PromptAgent."
)
# if provider is defined, use that, if possible with apiType, fallback to default_provider
module_name = mapping["package"]
class_name = mapping["name"]
module = __import__(module_name, fromlist=[class_name])
agent_class = getattr(module, class_name)
setup_dict[mapping["model_field"]] = prompt_agent.model.id
return agent_class(**setup_dict)
def _parse_chat_options(self, model: Model | None) -> dict[str, Any]:
"""Parse ModelOptions into chat options dictionary."""
chat_options: dict[str, Any] = {}
if not model or not model.options or not isinstance(model.options, ModelOptions):
return chat_options
options = model.options
if options.frequencyPenalty is not None:
chat_options["frequency_penalty"] = options.frequencyPenalty
if options.presencePenalty is not None:
chat_options["presence_penalty"] = options.presencePenalty
if options.maxOutputTokens is not None:
chat_options["max_tokens"] = options.maxOutputTokens
if options.temperature is not None:
chat_options["temperature"] = options.temperature
if options.topP is not None:
chat_options["top_p"] = options.topP
if options.seed is not None:
chat_options["seed"] = options.seed
if options.stopSequences:
chat_options["stop"] = options.stopSequences
if options.allowMultipleToolCalls is not None:
chat_options["allow_multiple_tool_calls"] = options.allowMultipleToolCalls
if (chat_tool_mode := options.additionalProperties.pop("chatToolMode", None)) is not None:
chat_options["tool_choice"] = chat_tool_mode
if options.additionalProperties:
chat_options["additional_chat_options"] = options.additionalProperties
return chat_options
def _parse_tools(self, tools: list[Tool] | None) -> list[AFFunctionTool | dict[str, Any]] | None:
"""Parse tool resources into AFFunctionTool instances or dict-based tools."""
if not tools:
return None
return [self._parse_tool(tool_resource) for tool_resource in tools]
def _parse_tool(self, tool_resource: Tool) -> AFFunctionTool | dict[str, Any]:
"""Parse a single tool resource into an AFFunctionTool instance."""
match tool_resource:
case FunctionTool():
func: Callable[..., Any] | None = None
if self.bindings and tool_resource.bindings:
for binding in tool_resource.bindings:
if binding.name and (func := self.bindings.get(binding.name)):
break
return AFFunctionTool(
name=tool_resource.name, # type: ignore
description=tool_resource.description, # type: ignore
input_model=tool_resource.parameters.to_json_schema() if tool_resource.parameters else None,
func=func,
)
case WebSearchTool():
result: dict[str, Any] = {"type": "web_search_preview"}
if tool_resource.description:
result["description"] = tool_resource.description
if tool_resource.options:
result.update(tool_resource.options)
return result
case FileSearchTool():
result = {
"type": "file_search",
"vector_store_ids": tool_resource.vectorStoreIds or [],
}
if tool_resource.maximumResultCount is not None:
result["max_num_results"] = tool_resource.maximumResultCount
if tool_resource.description:
result["description"] = tool_resource.description
if tool_resource.ranker is not None:
result["ranker"] = tool_resource.ranker
if tool_resource.scoreThreshold is not None:
result["score_threshold"] = tool_resource.scoreThreshold
if tool_resource.filters:
result["filters"] = tool_resource.filters
return result
case CodeInterpreterTool():
result = {"type": "code_interpreter"}
if tool_resource.fileIds:
result["file_ids"] = tool_resource.fileIds
if tool_resource.description:
result["description"] = tool_resource.description
return result
case McpTool():
result = {
"type": "mcp",
"server_label": tool_resource.name.replace(" ", "_") if tool_resource.name else "",
"server_url": str(tool_resource.url) if tool_resource.url else "",
}
if tool_resource.description:
result["server_description"] = tool_resource.description
if tool_resource.allowedTools:
result["allowed_tools"] = list(tool_resource.allowedTools)
# Handle approval mode
if tool_resource.approvalMode is not None:
if tool_resource.approvalMode.kind == "always":
result["require_approval"] = "always"
elif tool_resource.approvalMode.kind == "never":
result["require_approval"] = "never"
elif isinstance(tool_resource.approvalMode, McpServerToolSpecifyApprovalMode):
approval_config: dict[str, Any] = {}
if tool_resource.approvalMode.alwaysRequireApprovalTools:
approval_config["always"] = {
"tool_names": list(tool_resource.approvalMode.alwaysRequireApprovalTools)
}
if tool_resource.approvalMode.neverRequireApprovalTools:
approval_config["never"] = {
"tool_names": list(tool_resource.approvalMode.neverRequireApprovalTools)
}
if approval_config:
result["require_approval"] = approval_config
# Handle connection settings
if tool_resource.connection is not None:
match tool_resource.connection:
case ApiKeyConnection():
if tool_resource.connection.apiKey:
result["headers"] = {"Authorization": f"Bearer {tool_resource.connection.apiKey}"}
case RemoteConnection():
result["project_connection_id"] = tool_resource.connection.name
case ReferenceConnection():
result["project_connection_id"] = tool_resource.connection.name
case AnonymousConnection():
pass
case _:
raise ValueError(f"Unsupported connection kind: {tool_resource.connection.kind}")
return result
case _:
raise ValueError(f"Unsupported tool kind: {tool_resource.kind}")
def _retrieve_provider_configuration(self, model: Model) -> ProviderTypeMapping:
"""Retrieve the provider configuration based on the model's provider and apiType.
If only provider is specified, it will be used.
If both provider and apiType are specified, both will be used.
If neither is specified, the default_provider will be used.
Args:
model: The Model instance containing provider and apiType information.
Returns:
A dictionary containing the package, name, and model_field for the provider.
Raises:
ProviderLookupError: If the provider type is not supported or can't be found.
"""
class_lookup = (
f"{model.provider}.{model.apiType}"
if model.apiType
else f"{model.provider}"
if model.provider
else self.default_provider
)
if class_lookup in self.additional_mappings:
return self.additional_mappings[class_lookup]
if class_lookup not in PROVIDER_TYPE_OBJECT_MAPPING:
raise ProviderLookupError(f"Unsupported provider type: {class_lookup}")
return PROVIDER_TYPE_OBJECT_MAPPING[class_lookup]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,167 @@
# Copyright (c) Microsoft. All rights reserved.
"""Declarative workflow support for agent-framework.
This module provides the ability to create executable Workflow objects from YAML definitions,
enabling multi-agent orchestration patterns like Foreach, conditionals, and agent invocations.
Graph-based execution enables:
- Checkpointing at action boundaries
- Workflow visualization
- Pause/resume capabilities
- Full integration with the workflow runtime
"""
from ._declarative_base import (
DECLARATIVE_STATE_KEY,
ActionComplete,
ActionTrigger,
ConversationData,
DeclarativeActionExecutor,
DeclarativeMessage,
DeclarativeStateData,
DeclarativeWorkflowState,
LoopControl,
LoopIterationResult,
)
from ._declarative_builder import ALL_ACTION_EXECUTORS, DeclarativeWorkflowBuilder
from ._errors import DeclarativeActionError, DeclarativeWorkflowError
from ._executors_agents import (
AGENT_ACTION_EXECUTORS,
AGENT_REGISTRY_KEY,
TOOL_REGISTRY_KEY,
AgentExternalInputRequest,
AgentExternalInputResponse,
AgentResult,
ExternalLoopState,
InvokeAzureAgentExecutor,
)
from ._executors_basic import (
BASIC_ACTION_EXECUTORS,
ClearAllVariablesExecutor,
CreateConversationExecutor,
ResetVariableExecutor,
SendActivityExecutor,
SetMultipleVariablesExecutor,
SetTextVariableExecutor,
SetValueExecutor,
SetVariableExecutor,
)
from ._executors_control_flow import (
CONTROL_FLOW_EXECUTORS,
BreakLoopExecutor,
ContinueLoopExecutor,
EndConversationExecutor,
EndWorkflowExecutor,
ForeachInitExecutor,
ForeachNextExecutor,
JoinExecutor,
)
from ._executors_external_input import (
EXTERNAL_INPUT_EXECUTORS,
ExternalInputRequest,
ExternalInputResponse,
QuestionExecutor,
RequestExternalInputExecutor,
)
from ._executors_http import (
HTTP_ACTION_EXECUTORS,
HttpRequestActionExecutor,
)
from ._executors_mcp import (
MCP_ACTION_EXECUTORS,
InvokeMcpToolActionExecutor,
MCPToolApprovalRequest,
)
from ._executors_tools import (
FUNCTION_TOOL_REGISTRY_KEY,
TOOL_ACTION_EXECUTORS,
BaseToolExecutor,
InvokeFunctionToolExecutor,
ToolApprovalRequest,
ToolApprovalResponse,
ToolInvocationResult,
)
from ._factory import WorkflowFactory
from ._http_handler import (
DefaultHttpRequestHandler,
HttpRequestHandler,
HttpRequestInfo,
HttpRequestResult,
)
from ._mcp_handler import (
DefaultMCPToolHandler,
MCPToolHandler,
MCPToolInvocation,
MCPToolResult,
)
from ._state import WorkflowState
__all__ = [
"AGENT_ACTION_EXECUTORS",
"AGENT_REGISTRY_KEY",
"ALL_ACTION_EXECUTORS",
"BASIC_ACTION_EXECUTORS",
"CONTROL_FLOW_EXECUTORS",
"DECLARATIVE_STATE_KEY",
"EXTERNAL_INPUT_EXECUTORS",
"FUNCTION_TOOL_REGISTRY_KEY",
"HTTP_ACTION_EXECUTORS",
"MCP_ACTION_EXECUTORS",
"TOOL_ACTION_EXECUTORS",
"TOOL_REGISTRY_KEY",
"ActionComplete",
"ActionTrigger",
"AgentExternalInputRequest",
"AgentExternalInputResponse",
"AgentResult",
"BaseToolExecutor",
"BreakLoopExecutor",
"ClearAllVariablesExecutor",
"ContinueLoopExecutor",
"ConversationData",
"CreateConversationExecutor",
"DeclarativeActionError",
"DeclarativeActionExecutor",
"DeclarativeMessage",
"DeclarativeStateData",
"DeclarativeWorkflowBuilder",
"DeclarativeWorkflowError",
"DeclarativeWorkflowState",
"DefaultHttpRequestHandler",
"DefaultMCPToolHandler",
"EndConversationExecutor",
"EndWorkflowExecutor",
"ExternalInputRequest",
"ExternalInputResponse",
"ExternalLoopState",
"ForeachInitExecutor",
"ForeachNextExecutor",
"HttpRequestActionExecutor",
"HttpRequestHandler",
"HttpRequestInfo",
"HttpRequestResult",
"InvokeAzureAgentExecutor",
"InvokeFunctionToolExecutor",
"InvokeMcpToolActionExecutor",
"JoinExecutor",
"LoopControl",
"LoopIterationResult",
"MCPToolApprovalRequest",
"MCPToolHandler",
"MCPToolInvocation",
"MCPToolResult",
"QuestionExecutor",
"RequestExternalInputExecutor",
"ResetVariableExecutor",
"SendActivityExecutor",
"SetMultipleVariablesExecutor",
"SetTextVariableExecutor",
"SetValueExecutor",
"SetVariableExecutor",
"ToolApprovalRequest",
"ToolApprovalResponse",
"ToolInvocationResult",
"WorkflowFactory",
"WorkflowState",
]
@@ -0,0 +1,38 @@
# Copyright (c) Microsoft. All rights reserved.
"""Error types for declarative workflow executor modules.
This module exists so that executor modules and the builder (e.g.
``_executors_http``, ``_declarative_builder``) can raise declarative-specific
exceptions without importing from ``_factory``. ``_factory`` imports
``_declarative_builder`` which imports the executor modules; pulling
:class:`DeclarativeWorkflowError` from ``_factory`` into an executor or
builder module would therefore introduce a circular import.
"""
from __future__ import annotations
from agent_framework.exceptions import WorkflowException
class DeclarativeWorkflowError(WorkflowException):
"""Raised for build-time / factory-level declarative workflow errors.
Used for YAML parsing/validation issues, missing configuration (e.g. an
HTTP request handler not supplied for a workflow that contains an
``HttpRequestAction``), and other errors detected before workflow
execution begins.
"""
pass
class DeclarativeActionError(WorkflowException):
"""Raised when a declarative action fails at run time.
Used by executor modules for runtime failures (e.g. transport errors,
non-2xx responses from :class:`HttpRequestActionExecutor`). Build-time and
factory-level errors continue to use :class:`DeclarativeWorkflowError`.
"""
pass
@@ -0,0 +1,574 @@
# Copyright (c) Microsoft. All rights reserved.
"""Basic action executors for the graph-based declarative workflow system.
These executors handle simple actions like SetValue, SendActivity, etc.
Each action becomes a node in the workflow graph.
"""
import uuid
from collections.abc import Mapping
from typing import Any, cast
from agent_framework import (
WorkflowContext,
handler,
)
from ._declarative_base import (
ActionComplete,
DeclarativeActionExecutor,
)
def _get_variable_path(action_def: dict[str, Any], key: str = "variable") -> str | None:
"""Extract variable path from action definition.
Supports .NET style (variable: Local.VarName) and nested object style (variable: {path: ...}).
"""
variable = action_def.get(key)
if isinstance(variable, str):
return variable
if isinstance(variable, Mapping):
path = variable.get("path") # type: ignore[reportUnknownVariableType]
return path if isinstance(path, str) else None
fallback_path = action_def.get("path")
return fallback_path if isinstance(fallback_path, str) else None
class SetValueExecutor(DeclarativeActionExecutor):
"""Executor for the SetValue action.
Sets a value in the workflow state at a specified path.
"""
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Handle the SetValue action."""
state = await self._ensure_state_initialized(ctx, trigger)
path = self._action_def.get("path")
value = self._action_def.get("value")
if path:
# Evaluate value if it's an expression
evaluated_value = state.eval_if_expression(value)
state.set(path, evaluated_value)
await ctx.send_message(ActionComplete())
class SetVariableExecutor(DeclarativeActionExecutor):
"""Executor for the SetVariable action (.NET style naming)."""
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Handle the SetVariable action."""
state = await self._ensure_state_initialized(ctx, trigger)
path = _get_variable_path(self._action_def)
value = self._action_def.get("value")
if path:
evaluated_value = state.eval_if_expression(value)
state.set(path, evaluated_value)
await ctx.send_message(ActionComplete())
class CreateConversationExecutor(DeclarativeActionExecutor):
"""Executor for the CreateConversation action.
Generates a unique conversation ID and initialises a conversation entry
in ``System.conversations``. The generated ID is stored at the state
path specified by the ``conversationId`` parameter (if provided).
"""
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Handle the CreateConversation action."""
state = await self._ensure_state_initialized(ctx, trigger)
generated_id = str(uuid.uuid4())
# Store the generated ID at the requested path (e.g. "Local.myConvId")
conversation_id_path = _get_variable_path(self._action_def, "conversationId")
if conversation_id_path:
state.set(conversation_id_path, generated_id)
# Initialise the conversation entry in System.conversations
conversations: dict[str, Any] = state.get("System.conversations") or {}
conversations[generated_id] = {
"id": generated_id,
"messages": [],
}
state.set("System.conversations", conversations)
await ctx.send_message(ActionComplete())
class SetTextVariableExecutor(DeclarativeActionExecutor):
"""Executor for the SetTextVariable action."""
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Handle the SetTextVariable action."""
state = await self._ensure_state_initialized(ctx, trigger)
path = _get_variable_path(self._action_def)
text = self._action_def.get("text", "")
if path:
evaluated_text = state.eval_if_expression(text)
state.set(path, str(evaluated_text) if evaluated_text is not None else "")
await ctx.send_message(ActionComplete())
class SetMultipleVariablesExecutor(DeclarativeActionExecutor):
"""Executor for the SetMultipleVariables action."""
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Handle the SetMultipleVariables action."""
state = await self._ensure_state_initialized(ctx, trigger)
assignments = cast(
list[Mapping[str, Any]],
self._action_def.get("assignments") if isinstance(self._action_def.get("assignments"), list) else [],
)
for assignment in assignments:
if not isinstance(assignment, Mapping):
continue
variable = assignment.get("variable")
path: str | None
if isinstance(variable, str):
path = variable
elif isinstance(variable, Mapping):
path_value = variable.get("path") # type: ignore[reportUnknownMemberType]
path = path_value if isinstance(path_value, str) else None
else:
fallback_path = assignment.get("path")
path = fallback_path if isinstance(fallback_path, str) else None
value = assignment.get("value")
if path:
evaluated_value = state.eval_if_expression(value)
state.set(path, evaluated_value)
await ctx.send_message(ActionComplete())
class ResetVariableExecutor(DeclarativeActionExecutor):
"""Executor for the ResetVariable action."""
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Handle the ResetVariable action."""
state = await self._ensure_state_initialized(ctx, trigger)
path = _get_variable_path(self._action_def)
if path:
# Reset to None/empty
state.set(path, None)
await ctx.send_message(ActionComplete())
class ClearAllVariablesExecutor(DeclarativeActionExecutor):
"""Executor for the ClearAllVariables action."""
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Handle the ClearAllVariables action."""
state = await self._ensure_state_initialized(ctx, trigger)
# Get state data and clear Local variables
state_data = state.get_state_data()
state_data["Local"] = {}
state.set_state_data(state_data)
await ctx.send_message(ActionComplete())
class SendActivityExecutor(DeclarativeActionExecutor):
"""Executor for the SendActivity action.
Sends a text message or activity as workflow output.
"""
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete, str],
) -> None:
"""Handle the SendActivity action."""
state = await self._ensure_state_initialized(ctx, trigger)
activity = self._action_def.get("activity", "")
# Activity can be a string directly or a dict with a "text" field
if isinstance(activity, Mapping):
text: Any = activity.get("text", "") # type: ignore[reportUnknownMemberType]
else:
text = activity
if isinstance(text, str):
# First evaluate any =expression syntax
text = state.eval_if_expression(text)
# Then interpolate any {Variable.Path} template syntax
if isinstance(text, str):
text = state.interpolate_string(text)
# Yield the text as workflow output
if text:
await ctx.yield_output(str(text)) # type: ignore[reportUnknownArgumentType]
await ctx.send_message(ActionComplete())
class EditTableExecutor(DeclarativeActionExecutor):
"""Executor for the EditTable action.
Performs operations on a table (list) variable such as add, remove, or clear.
This is equivalent to the .NET EditTable action.
YAML example:
- kind: EditTable
table: Local.Items
operation: add # add, remove, clear
value: =Local.NewItem
index: 0 # optional, for insert at position
"""
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Handle the EditTable action."""
state = await self._ensure_state_initialized(ctx, trigger)
table_path = self._action_def.get("table") or _get_variable_path(self._action_def, "variable")
operation = self._action_def.get("operation", "add").lower()
value = self._action_def.get("value")
index = self._action_def.get("index")
if table_path:
# Get current table value
current_table_value = state.get(table_path)
current_table: list[Any]
if current_table_value is None:
current_table = []
elif isinstance(current_table_value, list):
current_table = list(current_table_value) # type: ignore[reportUnknownArgumentType]
else:
current_table = [current_table_value]
if operation == "add" or operation == "insert":
evaluated_value = state.eval_if_expression(value)
if index is not None:
evaluated_index = state.eval_if_expression(index)
idx = int(evaluated_index) if evaluated_index is not None else len(current_table)
current_table.insert(idx, evaluated_value)
else:
current_table.append(evaluated_value)
elif operation == "remove":
if value is not None:
# Remove by value
evaluated_value = state.eval_if_expression(value)
if evaluated_value in current_table:
current_table.remove(evaluated_value)
elif index is not None:
# Remove by index
evaluated_index = state.eval_if_expression(index)
idx = int(evaluated_index) if evaluated_index is not None else -1
if 0 <= idx < len(current_table):
current_table.pop(idx)
elif operation == "clear":
current_table = []
elif operation == "set" or operation == "update":
# Update item at index
if index is not None:
evaluated_value = state.eval_if_expression(value)
evaluated_index = state.eval_if_expression(index)
idx = int(evaluated_index) if evaluated_index is not None else 0
if 0 <= idx < len(current_table):
current_table[idx] = evaluated_value
state.set(table_path, current_table)
await ctx.send_message(ActionComplete())
class EditTableV2Executor(DeclarativeActionExecutor):
"""Executor for the EditTableV2 action.
Enhanced table editing with more operations and better record support.
This is equivalent to the .NET EditTableV2 action.
YAML example:
- kind: EditTableV2
table: Local.Records
operation: addOrUpdate # add, remove, clear, addOrUpdate, filter
item: =Local.NewRecord
key: id # for addOrUpdate, the field to match on
condition: =item.status = "active" # for filter operation
"""
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Handle the EditTableV2 action."""
state = await self._ensure_state_initialized(ctx, trigger)
table_path = self._action_def.get("table") or _get_variable_path(self._action_def, "variable")
operation = self._action_def.get("operation", "add").lower()
item = self._action_def.get("item") or self._action_def.get("value")
key_field = self._action_def.get("key")
index = self._action_def.get("index")
if table_path:
# Get current table value
current_table_value = state.get(table_path)
current_table: list[Any]
if current_table_value is None:
current_table = []
elif isinstance(current_table_value, list):
current_table = list(current_table_value) # type: ignore[reportUnknownArgumentType]
else:
current_table = [current_table_value]
if operation == "add":
evaluated_item = state.eval_if_expression(item)
if index is not None:
evaluated_index = state.eval_if_expression(index)
idx = int(evaluated_index) if evaluated_index is not None else len(current_table)
current_table.insert(idx, evaluated_item)
else:
current_table.append(evaluated_item)
elif operation == "remove":
if item is not None:
evaluated_item = state.eval_if_expression(item)
if key_field and isinstance(evaluated_item, dict):
# Remove by key match
evaluated_item_dict = cast(dict[str, Any], evaluated_item)
key_value = evaluated_item_dict.get(key_field)
current_table = [
r
for r in current_table
if not (isinstance(r, dict) and cast(dict[str, Any], r).get(key_field) == key_value)
]
elif evaluated_item in current_table:
current_table.remove(evaluated_item)
elif index is not None:
evaluated_index = state.eval_if_expression(index)
idx = int(evaluated_index) if evaluated_index is not None else -1
if 0 <= idx < len(current_table):
current_table.pop(idx)
elif operation == "clear":
current_table = []
elif operation == "addorupdate":
evaluated_item = state.eval_if_expression(item)
if key_field and isinstance(evaluated_item, dict):
key_value = evaluated_item.get(key_field) # type: ignore[reportUnknownArgumentType]
# Find existing item with same key
found_idx = -1
for i, r in enumerate(current_table):
if isinstance(r, dict) and cast(dict[str, Any], r).get(key_field) == key_value:
found_idx = i
break
if found_idx >= 0:
# Update existing
current_table[found_idx] = evaluated_item
else:
# Add new
current_table.append(evaluated_item)
else:
# No key field - just add
current_table.append(evaluated_item)
elif operation == "update":
evaluated_item = state.eval_if_expression(item)
if index is not None:
evaluated_index = state.eval_if_expression(index)
idx = int(evaluated_index) if evaluated_index is not None else 0
if 0 <= idx < len(current_table):
current_table[idx] = evaluated_item
elif key_field and isinstance(evaluated_item, dict):
key_value = evaluated_item.get(key_field) # type: ignore[reportUnknownArgumentType]
for i, r in enumerate(current_table):
if isinstance(r, dict) and cast(dict[str, Any], r).get(key_field) == key_value:
current_table[i] = evaluated_item
break
state.set(table_path, current_table)
await ctx.send_message(ActionComplete())
class ParseValueExecutor(DeclarativeActionExecutor):
"""Executor for the ParseValue action.
Parses a value expression and optionally converts it to a target type.
This is equivalent to the .NET ParseValue action.
YAML example:
- kind: ParseValue
variable: Local.ParsedData
value: =System.LastMessage.Text
valueType: object # optional: string, number, boolean, object, array
"""
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Handle the ParseValue action."""
state = await self._ensure_state_initialized(ctx, trigger)
path = _get_variable_path(self._action_def)
value = self._action_def.get("value")
value_type = self._action_def.get("valueType")
if path and value is not None:
# Evaluate the value expression
evaluated_value = state.eval_if_expression(value)
# Convert to target type if specified
if value_type:
evaluated_value = self._convert_to_type(evaluated_value, value_type)
state.set(path, evaluated_value)
await ctx.send_message(ActionComplete())
def _convert_to_type(self, value: Any, target_type: str) -> Any:
"""Convert a value to the specified target type.
Args:
value: The value to convert
target_type: Target type (string, number, boolean, object, array)
Returns:
The converted value
"""
import json
target_type = target_type.lower()
if target_type == "string":
if value is None:
return ""
return str(value)
if target_type in ("number", "int", "integer", "float", "decimal"):
if value is None:
return 0
if isinstance(value, str):
# Try to parse as number
try:
if "." in value:
return float(value)
return int(value)
except ValueError:
return 0
return float(value) if isinstance(value, (int, float)) else 0
if target_type in ("boolean", "bool"):
if value is None:
return False
if isinstance(value, str):
return value.lower() in ("true", "yes", "1", "on")
return bool(value)
if target_type in ("object", "record"):
if value is None:
return {}
if isinstance(value, dict):
return cast(dict[str, Any], value)
if isinstance(value, str):
try:
parsed = json.loads(value)
if isinstance(parsed, dict):
return cast(dict[str, Any], parsed)
return {"value": parsed}
except json.JSONDecodeError:
return {"value": value}
return {"value": value}
if target_type in ("array", "table", "list"):
if value is None:
return []
if isinstance(value, list):
return cast(list[Any], value)
if isinstance(value, str):
try:
parsed = json.loads(value)
if isinstance(parsed, list):
return cast(list[Any], parsed)
return [parsed]
except json.JSONDecodeError:
return [value]
return [value]
# Unknown type - return as-is
return value
# Mapping of action kinds to executor classes
BASIC_ACTION_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
"CreateConversation": CreateConversationExecutor,
"SetValue": SetValueExecutor,
"SetVariable": SetVariableExecutor,
"SetTextVariable": SetTextVariableExecutor,
"SetMultipleVariables": SetMultipleVariablesExecutor,
"ResetVariable": ResetVariableExecutor,
"ClearAllVariables": ClearAllVariablesExecutor,
"SendActivity": SendActivityExecutor,
"ParseValue": ParseValueExecutor,
"EditTable": EditTableExecutor,
"EditTableV2": EditTableV2Executor,
}
@@ -0,0 +1,461 @@
# Copyright (c) Microsoft. All rights reserved.
"""Control flow executors for the graph-based declarative workflow system.
Control flow in the graph-based system is handled differently than the interpreter:
- If/ConditionGroup: Condition evaluation happens in a dedicated evaluator executor that
returns a ConditionResult with the first-matching branch index. Edge conditions
then check the branch_index to route to the correct branch. This ensures only
one branch executes (first-match semantics), matching the interpreter behavior.
- Foreach: Loop iteration state managed in State + loop edges
- Goto: Edge to target action (handled by builder)
- Break/Continue: Special signals for loop control
The key insight is that control flow becomes GRAPH STRUCTURE, not executor logic.
"""
from typing import Any, cast
from agent_framework import (
Message,
WorkflowContext,
handler,
)
from ._declarative_base import (
ActionComplete,
ActionTrigger,
ConditionResult,
DeclarativeActionExecutor,
LoopControl,
LoopIterationResult,
)
# Keys for loop state in State
LOOP_STATE_KEY = "_declarative_loop_state"
# Index value indicating the else/default branch
ELSE_BRANCH_INDEX = -1
class ConditionGroupEvaluatorExecutor(DeclarativeActionExecutor):
"""Evaluates conditions for ConditionGroup and outputs the first-matching branch.
This executor implements first-match semantics by evaluating conditions sequentially
and outputting a ConditionResult with the index of the first matching branch.
Edge conditions downstream check this index to route to the correct branch.
This mirrors .NET's ConditionGroupExecutor.ExecuteAsync which returns the step ID
of the first matching condition.
"""
def __init__(
self,
action_def: dict[str, Any],
conditions: list[dict[str, Any]],
*,
id: str | None = None,
):
"""Initialize the condition evaluator.
Args:
action_def: The ConditionGroup action definition
conditions: List of condition items, each with 'condition' and optional 'id'
id: Optional executor ID
"""
super().__init__(action_def, id=id)
self._conditions = conditions
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ConditionResult],
) -> None:
"""Evaluate conditions and output the first matching branch index."""
state = await self._ensure_state_initialized(ctx, trigger)
# Evaluate conditions sequentially - first match wins
for index, cond_item in enumerate(self._conditions):
condition_expr = cond_item.get("condition")
if condition_expr is None:
continue
# Normalize boolean conditions
if condition_expr is True:
condition_expr = "=true"
elif condition_expr is False:
condition_expr = "=false"
elif isinstance(condition_expr, str) and not condition_expr.startswith("="):
condition_expr = f"={condition_expr}"
result = state.eval(condition_expr)
if bool(result):
# First matching condition found
await ctx.send_message(ConditionResult(matched=True, branch_index=index, value=result))
return
# No condition matched - use else/default branch
await ctx.send_message(ConditionResult(matched=False, branch_index=ELSE_BRANCH_INDEX))
class IfConditionEvaluatorExecutor(DeclarativeActionExecutor):
"""Evaluates a single If condition and outputs a ConditionResult.
This is simpler than ConditionGroupEvaluator - just evaluates one condition
and outputs branch_index=0 (then) or branch_index=-1 (else).
"""
def __init__(
self,
action_def: dict[str, Any],
condition_expr: str,
*,
id: str | None = None,
):
"""Initialize the if condition evaluator.
Args:
action_def: The If action definition
condition_expr: The condition expression to evaluate
id: Optional executor ID
"""
super().__init__(action_def, id=id)
self._condition_expr = condition_expr
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ConditionResult],
) -> None:
"""Evaluate the condition and output the result."""
state = await self._ensure_state_initialized(ctx, trigger)
result = state.eval(self._condition_expr)
is_truthy = bool(result)
if is_truthy:
await ctx.send_message(ConditionResult(matched=True, branch_index=0, value=result))
else:
await ctx.send_message(ConditionResult(matched=False, branch_index=ELSE_BRANCH_INDEX, value=result))
class ForeachInitExecutor(DeclarativeActionExecutor):
"""Initializes a foreach loop.
Sets up the loop state in State and determines if there are items.
"""
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[LoopIterationResult],
) -> None:
"""Initialize the loop and check for first item."""
state = await self._ensure_state_initialized(ctx, trigger)
items_expr = self._action_def.get("source")
items_raw: Any = state.eval_if_expression(items_expr) or []
items: list[Any]
items = (list(items_raw) if items_raw else []) if not isinstance(items_raw, (list, tuple)) else list(items_raw) # type: ignore
loop_id = self.id
# Store loop state
state_data = state.get_state_data()
loop_states: dict[str, Any] = cast(dict[str, Any], state_data).setdefault(LOOP_STATE_KEY, {})
loop_states[loop_id] = {
"items": items,
"index": 0,
"length": len(items),
}
state.set_state_data(state_data)
if items:
# Bind the current item and (when requested) the index under the Local scope.
item_var = f"Local.{self._action_def.get('itemName', 'item')}"
index_var = (
f"Local.{self._action_def.get('indexName', 'index')}" if "indexName" in self._action_def else None
)
state.set(item_var, items[0])
if index_var:
state.set(index_var, 0)
await ctx.send_message(LoopIterationResult(has_next=True, current_item=items[0], current_index=0))
else:
await ctx.send_message(LoopIterationResult(has_next=False))
class ForeachNextExecutor(DeclarativeActionExecutor):
"""Advances to the next item in a foreach loop.
This executor is triggered after the loop body completes.
"""
def __init__(
self,
action_def: dict[str, Any],
init_executor_id: str,
*,
id: str | None = None,
):
"""Initialize with reference to the init executor.
Args:
action_def: The Foreach action definition
init_executor_id: ID of the corresponding ForeachInitExecutor
id: Optional executor ID
"""
super().__init__(action_def, id=id)
self._init_executor_id = init_executor_id
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[LoopIterationResult],
) -> None:
"""Advance to next item and send result."""
state = await self._ensure_state_initialized(ctx, trigger)
loop_id = self._init_executor_id
# Get loop state
state_data = state.get_state_data()
loop_states: dict[str, Any] = cast(dict[str, Any], state_data).get(LOOP_STATE_KEY, {})
loop_state = loop_states.get(loop_id)
if not loop_state:
# No loop state - shouldn't happen but handle gracefully
await ctx.send_message(LoopIterationResult(has_next=False))
return
items = loop_state["items"]
current_index = loop_state["index"] + 1
if current_index < len(items):
# Update loop state
loop_state["index"] = current_index
state.set_state_data(state_data)
# Rebind the current item and (when requested) the index under the Local scope.
item_var = f"Local.{self._action_def.get('itemName', 'item')}"
index_var = (
f"Local.{self._action_def.get('indexName', 'index')}" if "indexName" in self._action_def else None
)
state.set(item_var, items[current_index])
if index_var:
state.set(index_var, current_index)
await ctx.send_message(
LoopIterationResult(has_next=True, current_item=items[current_index], current_index=current_index)
)
else:
# Loop complete - clean up
loop_states_dict = cast(dict[str, Any], state_data).get(LOOP_STATE_KEY, {})
if loop_id in loop_states_dict:
del loop_states_dict[loop_id]
state.set_state_data(state_data)
await ctx.send_message(LoopIterationResult(has_next=False))
@handler
async def handle_loop_control(
self,
control: LoopControl,
ctx: WorkflowContext[LoopIterationResult],
) -> None:
"""Handle break/continue signals."""
state = self._get_state(ctx.state)
if control.action == "break":
# Clean up loop state and signal done
state_data = state.get_state_data()
loop_states: dict[str, Any] = cast(dict[str, Any], state_data).get(LOOP_STATE_KEY, {})
if self._init_executor_id in loop_states:
del loop_states[self._init_executor_id]
state.set_state_data(state_data)
await ctx.send_message(LoopIterationResult(has_next=False))
elif control.action == "continue":
# Just advance to next iteration
await self.handle_action(ActionTrigger(), ctx)
class BreakLoopExecutor(DeclarativeActionExecutor):
"""Executor for BreakLoop action.
Sends a LoopControl signal to break out of the enclosing loop.
"""
def __init__(
self,
action_def: dict[str, Any],
loop_next_executor_id: str,
*,
id: str | None = None,
):
"""Initialize with reference to the loop's next executor.
Args:
action_def: The action definition
loop_next_executor_id: ID of the ForeachNextExecutor to signal
id: Optional executor ID
"""
super().__init__(action_def, id=id)
self._loop_next_executor_id = loop_next_executor_id
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[LoopControl],
) -> None:
"""Send break signal to the loop."""
await ctx.send_message(LoopControl(action="break"))
class ContinueLoopExecutor(DeclarativeActionExecutor):
"""Executor for ContinueLoop action.
Sends a LoopControl signal to continue to next iteration.
"""
def __init__(
self,
action_def: dict[str, Any],
loop_next_executor_id: str,
*,
id: str | None = None,
):
"""Initialize with reference to the loop's next executor.
Args:
action_def: The action definition
loop_next_executor_id: ID of the ForeachNextExecutor to signal
id: Optional executor ID
"""
super().__init__(action_def, id=id)
self._loop_next_executor_id = loop_next_executor_id
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[LoopControl],
) -> None:
"""Send continue signal to the loop."""
await ctx.send_message(LoopControl(action="continue"))
class EndWorkflowExecutor(DeclarativeActionExecutor):
"""Executor for EndWorkflow/EndDialog action.
This executor simply doesn't send any message, causing the workflow
to terminate at this point.
"""
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""End the workflow by not sending any continuation message."""
# Don't send ActionComplete - workflow ends here
pass
class EndConversationExecutor(DeclarativeActionExecutor):
"""Executor for EndConversation action."""
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""End the conversation."""
# For now, just don't continue
# In a full implementation, this would signal to close the conversation
pass
# Passthrough executor for joining control flow branches
class JoinExecutor(DeclarativeActionExecutor):
"""Executor that joins multiple branches back together.
Used after If/ConditionGroup to merge control flow back to a single path.
Also used as passthrough nodes for else/default branches.
"""
@handler
async def handle_action(
self,
trigger: dict[str, Any]
| str
| list[Message]
| ActionTrigger
| ActionComplete
| ConditionResult
| LoopIterationResult,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Simply pass through to continue the workflow."""
await self._ensure_state_initialized(ctx, trigger)
await ctx.send_message(ActionComplete())
class CancelDialogExecutor(DeclarativeActionExecutor):
"""Executor for CancelDialog action.
Cancels the current dialog/workflow, equivalent to .NET CancelDialog.
This terminates execution similarly to EndWorkflow.
"""
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Cancel the current dialog/workflow."""
# CancelDialog terminates execution without continuing
# Similar to EndWorkflow but semantically different (cancellation vs completion)
pass
class CancelAllDialogsExecutor(DeclarativeActionExecutor):
"""Executor for CancelAllDialogs action.
Cancels all dialogs in the execution stack, equivalent to .NET CancelAllDialogs.
This terminates the entire workflow execution.
"""
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Cancel all dialogs/workflows."""
# CancelAllDialogs terminates all execution
pass
# Mapping of control flow action kinds to executor classes
# Note: Most control flow is handled by the builder creating graph structure,
# these are the executors that are part of that structure
CONTROL_FLOW_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
"EndWorkflow": EndWorkflowExecutor,
"EndDialog": EndWorkflowExecutor,
"EndConversation": EndConversationExecutor,
"CancelDialog": CancelDialogExecutor,
"CancelAllDialogs": CancelAllDialogsExecutor,
}
@@ -0,0 +1,243 @@
# Copyright (c) Microsoft. All rights reserved.
"""External input executors for declarative workflows.
These executors handle interactions that require external input (user questions
and external integrations), using the request_info pattern to pause the workflow
and wait for responses.
"""
import uuid
from dataclasses import dataclass, field
from typing import Any, cast
from agent_framework import (
WorkflowContext,
handler,
response_handler,
)
from ._declarative_base import (
ActionComplete,
DeclarativeActionExecutor,
)
def _get_prompt_text(action_def: dict[str, Any], primary_key: str, fallback_key: str) -> Any:
"""Return the prompt text from an action definition.
Accepts a nested ``{primary_key: {"text": ...}}`` mapping, a bare
string under ``primary_key``, or a top-level ``fallback_key`` value.
"""
match action_def.get(primary_key):
case {"text": text}:
return text
case str() as text:
return text
case _:
return action_def.get(fallback_key, "")
def _get_output_path(action_def: dict[str, Any], default: str) -> str:
"""Return the state path where the action result should be written.
Looks at ``variable``, then ``output.property``, then top-level
``property``, falling back to ``default``.
"""
output = action_def.get("output")
nested = cast(dict[str, Any], output).get("property") if isinstance(output, dict) else None
return action_def.get("variable") or nested or action_def.get("property") or default
@dataclass
class ExternalInputRequest:
"""Request for external input (triggers workflow pause).
Aligns with .NET ExternalInputRequest pattern. Used by Question and
RequestExternalInput executors to signal that user input is needed.
The workflow will pause via request_info and wait for an ExternalInputResponse.
Attributes:
request_id: Unique identifier for this request.
message: The prompt or question to display to the user.
request_type: A free-form discriminator describing the kind of input
being requested. ``QuestionExecutor`` emits ``"question"`` and
``RequestExternalInputExecutor`` defaults to ``"external"``; callers
may supply any other string via the ``requestType`` field on a
``RequestExternalInput`` action (e.g. ``"approval"``) and it is
propagated unchanged.
metadata: Additional context (choices, output_property, timeout, etc.).
"""
request_id: str
message: str
request_type: str = "external"
metadata: dict[str, Any] = field(default_factory=dict) # type: ignore
@dataclass
class ExternalInputResponse:
"""Response to an ExternalInputRequest.
Provided by the caller to resume workflow execution with user input.
Attributes:
user_input: The user's text response.
value: Optional typed value (e.g., bool for confirmations, selected choice).
"""
user_input: str
value: Any = None
class QuestionExecutor(DeclarativeActionExecutor):
"""Executor that asks the user a question and waits for a response.
Uses the request_info pattern to pause execution until the user provides an answer.
The response is stored in workflow state at the configured output property.
"""
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Ask the question and wait for a response."""
state = await self._ensure_state_initialized(ctx, trigger)
question_text = _get_prompt_text(self._action_def, primary_key="question", fallback_key="text")
output_property = _get_output_path(self._action_def, default="Local.answer")
default_value = self._action_def.get("default", self._action_def.get("defaultValue"))
choices = self._action_def.get("choices", [])
allow_free_text = self._action_def.get("allowFreeText", True)
evaluated_question = state.eval_if_expression(question_text)
# Build choices metadata
choices_data: list[dict[str, str]] | None = None
if choices:
choices_data = []
for c in choices:
if isinstance(c, dict):
c_dict: dict[str, Any] = dict(c) # type: ignore[arg-type]
choices_data.append({
"value": c_dict.get("value", ""),
"label": c_dict.get("label") or c_dict.get("value", ""),
})
else:
choices_data.append({"value": str(c), "label": str(c)})
# Store output property in shared state for response handler
ctx.state.set("_question_output_property", output_property)
ctx.state.set("_question_default_value", default_value)
# Request external input - workflow pauses here
await ctx.request_info(
ExternalInputRequest(
request_id=str(uuid.uuid4()),
message=str(evaluated_question),
request_type="question",
metadata={
"output_property": output_property,
"choices": choices_data,
"allow_free_text": allow_free_text,
"default_value": default_value,
},
),
ExternalInputResponse,
)
@response_handler
async def handle_response(
self,
original_request: ExternalInputRequest,
response: ExternalInputResponse,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Handle the user's response to the question."""
state = self._get_state(ctx.state)
output_property = original_request.metadata.get("output_property", "Local.answer")
answer = response.value if response.value is not None else response.user_input
if output_property:
state.set(output_property, answer)
await ctx.send_message(ActionComplete())
class RequestExternalInputExecutor(DeclarativeActionExecutor):
"""Executor that requests external input/approval.
Used for complex external integrations beyond simple questions,
such as approval workflows, document uploads, or external system integrations.
"""
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Request external input."""
state = await self._ensure_state_initialized(ctx, trigger)
message = _get_prompt_text(self._action_def, primary_key="prompt", fallback_key="message")
output_property = _get_output_path(self._action_def, default="Local.externalInput")
default_value = self._action_def.get("default")
request_type = self._action_def.get("requestType", "external")
timeout_seconds = self._action_def.get("timeout")
required_fields = self._action_def.get("requiredFields", [])
metadata = self._action_def.get("metadata", {})
evaluated_message = state.eval_if_expression(message)
# Build request metadata
request_metadata: dict[str, Any] = {
**metadata,
"output_property": output_property,
"required_fields": required_fields,
"default_value": default_value,
}
if timeout_seconds:
request_metadata["timeout_seconds"] = timeout_seconds
# Request external input - workflow pauses here
await ctx.request_info(
ExternalInputRequest(
request_id=str(uuid.uuid4()),
message=str(evaluated_message),
request_type=request_type,
metadata=request_metadata,
),
ExternalInputResponse,
)
@response_handler
async def handle_response(
self,
original_request: ExternalInputRequest,
response: ExternalInputResponse,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Handle the external input response."""
state = self._get_state(ctx.state)
output_property = original_request.metadata.get("output_property", "Local.externalInput")
# Store the response value or user_input
result = response.value if response.value is not None else response.user_input
if output_property:
state.set(output_property, result)
await ctx.send_message(ActionComplete())
# Mapping of external input action kinds to executor classes
EXTERNAL_INPUT_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
"Question": QuestionExecutor,
"RequestExternalInput": RequestExternalInputExecutor,
}
@@ -0,0 +1,417 @@
# Copyright (c) Microsoft. All rights reserved.
"""Executor for the ``HttpRequestAction`` declarative action.
Mirrors the .NET ``HttpRequestExecutor``: dispatches an HTTP request through the
configured :class:`HttpRequestHandler`, parses the response body, and assigns
the parsed body and response headers to the declared state paths.
Security note: response bodies can echo secrets and may be very large. Diagnostic
messages produced for non-2xx responses truncate the body to 256 characters and
collapse CR/LF/TAB to spaces (parity with .NET ``FormatBodyForDiagnostics``).
"""
from __future__ import annotations
import json
import logging
from collections.abc import Mapping
from typing import Any
import httpx
from agent_framework import (
Message,
WorkflowContext,
handler,
)
from ._declarative_base import (
ActionComplete,
DeclarativeActionExecutor,
DeclarativeWorkflowState,
)
from ._errors import DeclarativeActionError
from ._http_handler import HttpRequestHandler, HttpRequestInfo, HttpRequestResult
__all__ = [
"HTTP_ACTION_EXECUTORS",
"HttpRequestActionExecutor",
]
logger = logging.getLogger(__name__)
_MAX_BODY_DIAGNOSTIC_LENGTH = 256
_BODY_TRUNCATION_SUFFIX = " \u2026 [truncated]"
# Body discriminator aliases. Long forms match the .NET object-model type
# names so YAML produced by .NET round-trips. Short forms are the .NET YAML
# convention used in test fixtures.
_BODY_KIND_JSON = {"json", "JsonRequestContent"}
_BODY_KIND_RAW = {"raw", "RawRequestContent"}
_BODY_KIND_NONE = {"none", "NoRequestContent"}
def _get_path(action_def: Mapping[str, Any], key: str) -> str | None:
"""Extract a state path from ``response``/``responseHeaders`` field.
Supports two YAML shapes (matches .NET serialization round-trips):
- ``response: Local.MyVar`` (plain string).
- ``response: { path: Local.MyVar }`` (object form).
"""
value = action_def.get(key)
if isinstance(value, str):
return value or None
if isinstance(value, Mapping):
path = value.get("path") # type: ignore[reportUnknownMemberType, reportUnknownVariableType]
return path if isinstance(path, str) and path else None
return None
def _format_body_for_diagnostics(body: str | None) -> str:
"""Truncate and sanitise a response body for inclusion in error messages.
Mirrors the .NET ``FormatBodyForDiagnostics`` helper:
- Empty/None -> empty string.
- Replaces CR/LF/TAB with spaces.
- Truncates to 256 chars with a unicode-ellipsis ``[truncated]`` suffix.
"""
if not body:
return ""
truncated = len(body) > _MAX_BODY_DIAGNOSTIC_LENGTH
head = body[:_MAX_BODY_DIAGNOSTIC_LENGTH] if truncated else body
sanitized = head.replace("\r", " ").replace("\n", " ").replace("\t", " ")
return sanitized + _BODY_TRUNCATION_SUFFIX if truncated else sanitized
def _parse_response_body(body: str | None) -> Any:
"""Parse an HTTP response body the same way the .NET executor does.
JSON-first: if the body parses as JSON, the parsed value is returned. Other
bodies are returned as the raw string. Empty/None bodies return ``None``.
"""
if body is None or body == "":
return None
try:
return json.loads(body)
except json.JSONDecodeError:
return body
def _format_query_value(value: Any) -> str | None:
"""Format a query-parameter value for URL inclusion.
Mirrors .NET ``FormatQueryValue``: ``None`` is dropped, ``bool`` becomes
lower-case ``"true"``/``"false"``, numerics use invariant ``str()``, and
other values fall through to ``str()``.
"""
if value is None:
return None
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, str):
return value
return str(value)
def _get_messages_path(state: DeclarativeWorkflowState, conversation_id_expr: str | None) -> str | None:
"""Return the configured conversation messages path, if any.
Returns ``System.conversations.{evaluated_id}.messages`` when a
``conversation_id_expr`` is configured and evaluates to a non-empty value.
Returns ``None`` when no conversation id expression is configured or when
the expression evaluates to ``None`` or an empty string (matches .NET
``GetConversationId`` behaviour where empty becomes ``null`` and the
response is not appended).
"""
if not conversation_id_expr:
return None
evaluated = state.eval_if_expression(conversation_id_expr)
if evaluated is None or (isinstance(evaluated, str) and not evaluated):
return None
return f"System.conversations.{evaluated}.messages"
class HttpRequestActionExecutor(DeclarativeActionExecutor):
"""Executor for the ``HttpRequestAction`` declarative action.
Dispatches through the supplied :class:`HttpRequestHandler` and:
- Parses the response body (JSON-first, raw string fall-back).
- Assigns the parsed body to ``response`` path (if configured).
- Folds multi-value response headers (comma-joined) and assigns them to
``responseHeaders`` path (if configured).
- On 2xx with non-empty body and a configured ``conversationId``, appends
an Assistant :class:`agent_framework.Message` to
``System.conversations.{id}.messages``.
- On non-2xx, still publishes ``responseHeaders`` (diagnostic) and raises
:class:`DeclarativeActionError` with a status-coded message containing a
truncated/sanitised body preview.
Transport errors (``httpx.TimeoutException``, ``TimeoutError``,
``httpx.HTTPError``) become :class:`DeclarativeActionError`. ``CancelledError``
is intentionally NOT caught so that workflow cancellation propagates.
"""
def __init__(
self,
action_def: dict[str, Any],
*,
id: str | None = None,
http_request_handler: HttpRequestHandler,
) -> None:
"""Create an HTTP request action executor.
Args:
action_def: Parsed ``HttpRequestAction`` YAML dict.
id: Optional executor id (defaults to action id or generated).
http_request_handler: Handler used to dispatch HTTP requests.
Required: the builder enforces presence at workflow-build time.
"""
super().__init__(action_def, id=id)
self._http_request_handler = http_request_handler
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Execute the HTTP request action."""
state = await self._ensure_state_initialized(ctx, trigger)
method = self._get_method(state)
url = self._get_url(state)
headers = self._get_headers(state)
query_parameters = self._get_query_parameters(state)
body, body_content_type = self._get_body(state)
timeout_ms = self._get_timeout_ms(state)
conversation_id_expr = self._action_def.get("conversationId")
connection_name = self._get_connection_name(state)
info = HttpRequestInfo(
method=method,
url=url,
headers=headers or {},
query_parameters=query_parameters or {},
body=body,
body_content_type=body_content_type,
timeout_ms=timeout_ms,
connection_name=connection_name,
)
try:
result = await self._http_request_handler.send(info)
except (httpx.TimeoutException, TimeoutError) as exc:
raise DeclarativeActionError(f"HTTP request to '{url}' timed out.") from exc
except DeclarativeActionError:
raise
except httpx.HTTPError as exc:
raise DeclarativeActionError(f"HTTP request to '{url}' failed: {type(exc).__name__}") from exc
except Exception as exc:
# Custom HttpRequestHandler implementations may raise arbitrary
# exception types. Wrap them in DeclarativeActionError so workflow
# error handling stays uniform regardless of transport. Note that
# ``asyncio.CancelledError`` is a ``BaseException`` (not
# ``Exception``) and so still propagates unmodified, preserving
# workflow-cancellation semantics.
raise DeclarativeActionError(f"HTTP request to '{url}' failed: {type(exc).__name__}") from exc
if result.is_success_status_code:
self._assign_response(state, result)
self._assign_response_headers(state, result)
self._append_response_to_conversation(state, conversation_id_expr, result.body)
await ctx.send_message(ActionComplete())
return
# Non-success path: still publish headers diagnostically, then raise.
self._assign_response_headers(state, result)
body_preview = _format_body_for_diagnostics(result.body)
if body_preview:
message = f"HTTP request to '{url}' failed with status code {result.status_code}. Body: '{body_preview}'"
else:
message = f"HTTP request to '{url}' failed with status code {result.status_code}."
raise DeclarativeActionError(message)
# ----- Field resolution ----------------------------------------------------
def _get_method(self, state: DeclarativeWorkflowState) -> str:
method = self._action_def.get("method")
evaluated = state.eval_if_expression(method) if method is not None else None
if not evaluated:
return "GET"
return str(evaluated).upper()
def _get_url(self, state: DeclarativeWorkflowState) -> str:
raw = self._action_def.get("url")
if raw is None:
raise ValueError("HttpRequestAction requires a 'url' field.")
evaluated = state.eval_if_expression(raw)
if not isinstance(evaluated, str) or not evaluated:
raise ValueError("HttpRequestAction 'url' evaluated to an empty value.")
return evaluated
def _get_headers(self, state: DeclarativeWorkflowState) -> dict[str, str] | None:
raw_headers = self._action_def.get("headers")
if not isinstance(raw_headers, Mapping) or not raw_headers:
return None
result: dict[str, str] = {}
for key, value in raw_headers.items(): # type: ignore[reportUnknownVariableType]
if not isinstance(key, str) or not key:
continue
evaluated = state.eval_if_expression(value)
if evaluated is None:
continue
text = str(evaluated)
if not text:
continue
result[key] = text
return result or None
def _get_query_parameters(self, state: DeclarativeWorkflowState) -> dict[str, str] | None:
raw_params = self._action_def.get("queryParameters")
if not isinstance(raw_params, Mapping) or not raw_params:
return None
result: dict[str, str] = {}
for key, value in raw_params.items(): # type: ignore[reportUnknownVariableType]
if not isinstance(key, str) or not key or value is None:
continue
evaluated = state.eval_if_expression(value)
formatted = _format_query_value(evaluated)
if formatted is not None:
result[key] = formatted
return result or None
def _get_body(self, state: DeclarativeWorkflowState) -> tuple[str | None, str | None]:
raw_body = self._action_def.get("body")
if raw_body is None:
return None, None
if not isinstance(raw_body, Mapping):
raise ValueError(
"HttpRequestAction 'body' must be a mapping with a 'kind' field (json, raw) or omitted entirely."
)
kind_value: Any = raw_body.get("kind") or raw_body.get("$kind") # type: ignore[reportUnknownMemberType]
if kind_value is None:
raise ValueError(
"HttpRequestAction 'body' is missing 'kind'. Use 'json', 'raw', or omit 'body' for no request body."
)
if not isinstance(kind_value, str):
raise ValueError(f"HttpRequestAction 'body.kind' must be a string, got {kind_value!r}.")
if kind_value in _BODY_KIND_NONE:
return None, None
if kind_value in _BODY_KIND_JSON:
content_expr: Any = raw_body.get("content") # type: ignore[reportUnknownMemberType]
if content_expr is None:
return None, None
evaluated = state.eval_if_expression(content_expr)
try:
body_text = json.dumps(evaluated, default=str)
except (TypeError, ValueError) as exc:
raise ValueError(f"HttpRequestAction 'body.content' could not be serialised as JSON: {exc}") from exc
return body_text, "application/json"
if kind_value in _BODY_KIND_RAW:
content_expr = raw_body.get("content") # type: ignore[reportUnknownMemberType]
content_type_expr: Any = raw_body.get("contentType") # type: ignore[reportUnknownMemberType]
content: str | None = None
if content_expr is not None:
evaluated = state.eval_if_expression(content_expr)
content = None if evaluated is None else str(evaluated)
content_type: str | None = None
if content_type_expr is not None:
ct_eval = state.eval_if_expression(content_type_expr)
ct_text = None if ct_eval is None else str(ct_eval)
content_type = ct_text or None
# Match .NET RawRequestContent semantics: when a raw body is sent
# without an explicit content type, default to text/plain so the
# request is interpretable by servers.
if content is not None and not content_type:
content_type = "text/plain"
return content, content_type
raise ValueError(
f"HttpRequestAction 'body.kind' has unsupported value '{kind_value}'. "
"Expected one of: json, raw, JsonRequestContent, RawRequestContent, "
"NoRequestContent."
)
def _get_timeout_ms(self, state: DeclarativeWorkflowState) -> int | None:
raw = self._action_def.get("requestTimeoutInMilliseconds")
if raw is None:
return None
evaluated = state.eval_if_expression(raw)
if evaluated is None:
return None
try:
value = int(evaluated)
except (TypeError, ValueError):
logger.debug(
"HttpRequestAction: ignoring non-numeric requestTimeoutInMilliseconds=%r",
evaluated,
)
return None
return value if value > 0 else None
def _get_connection_name(self, state: DeclarativeWorkflowState) -> str | None:
connection = self._action_def.get("connection")
if not isinstance(connection, Mapping):
return None
name_expr: Any = connection.get("name") # type: ignore[reportUnknownMemberType]
if name_expr is None:
return None
evaluated = state.eval_if_expression(name_expr)
if evaluated is None:
return None
text = str(evaluated)
return text or None
# ----- Result handling -----------------------------------------------------
def _assign_response(self, state: DeclarativeWorkflowState, result: HttpRequestResult) -> None:
path = _get_path(self._action_def, "response")
if path is None:
return
state.set(path, _parse_response_body(result.body))
def _assign_response_headers(self, state: DeclarativeWorkflowState, result: HttpRequestResult) -> None:
path = _get_path(self._action_def, "responseHeaders")
if path is None:
return
if not result.headers:
state.set(path, None)
return
# Fold multi-value headers with commas (standard HTTP folding) only at
# assignment time. The raw multi-value dict on HttpRequestResult.headers
# is left untouched so callers/tests can inspect duplicates.
flattened: dict[str, str] = {}
for key, values in result.headers.items():
flattened[key] = ",".join(values)
state.set(path, flattened)
def _append_response_to_conversation(
self,
state: DeclarativeWorkflowState,
conversation_id_expr: str | None,
body: str,
) -> None:
if not body:
return
messages_path = _get_messages_path(state, conversation_id_expr)
if messages_path is None:
return
# Mirrors InvokeAzureAgentExecutor: rely on state.append to lazily
# create the conversation entry. Avoids re-parsing the id back out
# of the dotted path string.
message = Message(role="assistant", contents=[body])
state.append(messages_path, message)
HTTP_ACTION_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
"HttpRequestAction": HttpRequestActionExecutor,
}
@@ -0,0 +1,549 @@
# Copyright (c) Microsoft. All rights reserved.
"""Executor for the ``InvokeMcpTool`` declarative action.
Mirrors the .NET ``InvokeMcpToolExecutor``: dispatches an MCP tool call through
the configured :class:`MCPToolHandler`, parses tool outputs, and routes
results to the configured ``output.{result, messages, autoSend}`` paths and
optional conversation history. Supports a human-in-loop approval flow via
``ctx.request_info()`` / :func:`@response_handler` for ``requireApproval=true``.
Security notes:
- Approval requests surface header NAMES only; header values are not echoed,
matching the posture of :mod:`._executors_http`.
- :class:`MCPToolApprovalRequest` carries the values the resume handler will
use; header values are re-evaluated on resume to keep secrets out of
checkpoint state.
- Tool outputs flow back into agent conversations through ``conversationId``
and through Tool-role messages emitted to ``output.messages``. They share
the same prompt-injection risk surface as ``HttpRequestAction``: workflow
authors must trust the MCP server they invoke.
"""
import json
import logging
import uuid
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any
import httpx
from agent_framework import (
Content,
Message,
WorkflowContext,
handler,
response_handler,
)
from agent_framework.exceptions import ToolExecutionException
from ._declarative_base import (
ActionComplete,
DeclarativeActionExecutor,
DeclarativeWorkflowState,
)
from ._executors_tools import ToolApprovalResponse
from ._mcp_handler import MCPToolHandler, MCPToolInvocation, MCPToolResult
__all__ = [
"MCP_ACTION_EXECUTORS",
"InvokeMcpToolActionExecutor",
"MCPToolApprovalRequest",
]
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Request / state types
# ---------------------------------------------------------------------------
@dataclass
class MCPToolApprovalRequest:
"""Approval request emitted before invoking an MCP tool.
Attributes:
request_id: Identifier matching the framework's pending-request key.
tool_name: Evaluated tool name.
server_url: Evaluated MCP server URL.
server_label: Optional human-readable label.
arguments: Evaluated tool arguments.
header_names: Outbound header names (values withheld).
connection_name: Connection identifier the invocation will use.
metadata: Internal routing data pinned at approval-request time
(e.g. ``conversation_id``) for use by the resume handler.
"""
request_id: str
tool_name: str
server_url: str
server_label: str | None
arguments: dict[str, Any]
header_names: list[str] = field(default_factory=lambda: [])
connection_name: str | None = None
metadata: dict[str, Any] = field(default_factory=lambda: {})
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _evaluate_conversation_id(state: DeclarativeWorkflowState, conversation_id_expr: Any) -> str | None:
"""Return the evaluated ``conversationId`` string, or None when empty/unset."""
if not isinstance(conversation_id_expr, str) or not conversation_id_expr:
return None
evaluated = state.eval_if_expression(conversation_id_expr)
if evaluated is None:
return None
text = str(evaluated)
return text or None
def _get_output_path(action_def: Mapping[str, Any], key: str) -> str | None:
"""Extract a state path from ``output.{key}`` field.
Supports two YAML shapes:
- ``output: { result: Local.MyVar }`` — plain string.
- ``output: { result: { path: Local.MyVar } }`` — object form.
"""
output: Any = action_def.get("output")
if not isinstance(output, Mapping):
return None
value: Any = output.get(key) # type: ignore[reportUnknownMemberType]
if isinstance(value, str):
return value or None
if isinstance(value, Mapping):
path: Any = value.get("path") # type: ignore[reportUnknownMemberType]
return path if isinstance(path, str) and path else None
return None
def _format_outputs_for_send(parsed_results: list[Any]) -> str:
"""Render parsed MCP outputs to a string for ``ctx.yield_output(...)``.
- Empty list → ``""``.
- All-string list → newline-joined.
- Single element (any type — scalar, dict, list) → JSON-dumped element.
This avoids surprising ``"[42]"`` / ``"[true]"`` / ``"[null]"`` when
an MCP tool returns a single scalar JSON value.
- Multi-element non-string list → JSON-dump the whole list.
"""
if not parsed_results:
return ""
if all(isinstance(item, str) for item in parsed_results):
return "\n".join(parsed_results)
if len(parsed_results) == 1:
return json.dumps(parsed_results[0], ensure_ascii=False)
return json.dumps(parsed_results, ensure_ascii=False)
# ---------------------------------------------------------------------------
# Executor
# ---------------------------------------------------------------------------
class InvokeMcpToolActionExecutor(DeclarativeActionExecutor):
"""Executor for the ``InvokeMcpTool`` declarative action.
Dispatches through the supplied :class:`MCPToolHandler` and:
- Evaluates ``serverUrl`` / ``toolName`` / ``serverLabel`` / ``arguments``
/ ``headers`` / ``connection.name`` from the action definition.
- When ``requireApproval=true``: emits a :class:`MCPToolApprovalRequest`
via ``ctx.request_info()`` and yields. On resume, the response is
checked; on rejection, ``output.result`` is set to ``"Error: ..."`` and
no tool call is made.
- On success: parses each :class:`agent_framework.Content` output (text →
JSON-first / data / uri → URI string) and assigns the parsed list to
``output.result``. Builds a single Tool-role :class:`Message`
containing all output contents and assigns it to ``output.messages``.
When ``output.autoSend`` is true (default), emits the rendered string
via ``ctx.yield_output(...)``. When ``conversationId`` is configured,
appends an Assistant-role :class:`Message` with the same contents to
``System.conversations.{id}.messages``.
- On error returned by the handler (``is_error=True``): assigns
``"Error: <message>"`` to ``output.result`` and completes normally
(parity with .NET ``AssignErrorAsync``).
.. note::
``output.messages`` receives a SINGLE Tool-role :class:`Message`
(containing the full tool output as ``contents``), unlike
:class:`agent_framework_declarative.InvokeFunctionToolExecutor` which
writes a list of two messages (assistant call + tool result). This
matches the .NET ``InvokeMcpToolExecutor`` output contract.
"""
def __init__(
self,
action_def: dict[str, Any],
*,
id: str | None = None,
mcp_tool_handler: MCPToolHandler,
) -> None:
"""Create an MCP tool action executor.
Args:
action_def: Parsed ``InvokeMcpTool`` YAML dict.
id: Optional executor id (defaults to action id or generated).
mcp_tool_handler: Handler used to dispatch MCP tool calls.
Required: the builder enforces presence at workflow-build
time.
"""
super().__init__(action_def, id=id)
self._mcp_tool_handler = mcp_tool_handler
# ----- Main handler --------------------------------------------------------
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete, str],
) -> None:
"""Execute the MCP tool action."""
state = await self._ensure_state_initialized(ctx, trigger)
server_url = self._get_server_url(state)
tool_name = self._get_tool_name(state)
server_label = self._get_server_label(state)
arguments = self._get_arguments(state)
headers = self._get_headers(state)
connection_name = self._get_connection_name(state)
require_approval = self._get_require_approval(state)
auto_send = self._get_auto_send(state)
conversation_id_expr = self._action_def.get("conversationId")
output_messages_path = _get_output_path(self._action_def, "messages")
output_result_path = _get_output_path(self._action_def, "result")
if require_approval:
request_id = str(uuid.uuid4())
conversation_id = _evaluate_conversation_id(state, conversation_id_expr)
request = MCPToolApprovalRequest(
request_id=request_id,
tool_name=tool_name,
server_url=server_url,
server_label=server_label,
arguments=arguments,
header_names=sorted(headers.keys()),
connection_name=connection_name,
metadata={"conversation_id": conversation_id},
)
logger.info(
"%s: requesting approval for MCP tool '%s' on '%s'",
self.__class__.__name__,
tool_name,
server_url,
)
await ctx.request_info(request, ToolApprovalResponse, request_id=request_id)
return
# No approval required - invoke directly.
invocation = MCPToolInvocation(
server_url=server_url,
tool_name=tool_name,
server_label=server_label,
arguments=arguments,
headers=headers,
connection_name=connection_name,
)
result = await self._invoke_with_narrow_catch(invocation)
await self._process_result(
ctx=ctx,
state=state,
result=result,
auto_send=auto_send,
conversation_id=_evaluate_conversation_id(state, conversation_id_expr),
output_messages_path=output_messages_path,
output_result_path=output_result_path,
)
await ctx.send_message(ActionComplete())
# ----- Approval response handler ------------------------------------------
@response_handler
async def handle_approval_response(
self,
original_request: MCPToolApprovalRequest,
response: ToolApprovalResponse,
ctx: WorkflowContext[ActionComplete, str],
) -> None:
"""Resume the invocation using the values pinned on ``original_request``."""
state = self._get_state(ctx.state)
tool_name = original_request.tool_name
metadata: dict[str, Any] = getattr(original_request, "metadata", None) or {}
raw_conversation_id = metadata.get("conversation_id")
conversation_id = raw_conversation_id if isinstance(raw_conversation_id, str) and raw_conversation_id else None
auto_send = self._get_auto_send(state)
output_messages_path = _get_output_path(self._action_def, "messages")
output_result_path = _get_output_path(self._action_def, "result")
if not response.approved:
logger.info(
"%s: MCP tool '%s' rejected: %s",
self.__class__.__name__,
tool_name,
response.reason,
)
self._assign_error(state, output_result_path, "MCP tool invocation was not approved by user.")
await ctx.send_message(ActionComplete())
return
invocation = MCPToolInvocation(
server_url=original_request.server_url,
tool_name=tool_name,
server_label=original_request.server_label,
arguments=original_request.arguments,
headers=self._evaluate_headers(state, self._action_def.get("headers")),
connection_name=getattr(original_request, "connection_name", None),
)
result = await self._invoke_with_narrow_catch(invocation)
await self._process_result(
ctx=ctx,
state=state,
result=result,
auto_send=auto_send,
conversation_id=conversation_id,
output_messages_path=output_messages_path,
output_result_path=output_result_path,
)
await ctx.send_message(ActionComplete())
# ----- Field resolution ----------------------------------------------------
def _get_server_url(self, state: DeclarativeWorkflowState) -> str:
raw = self._action_def.get("serverUrl")
if raw is None:
raise ValueError("InvokeMcpTool requires a 'serverUrl' field.")
evaluated = state.eval_if_expression(raw)
if not isinstance(evaluated, str) or not evaluated:
raise ValueError("InvokeMcpTool 'serverUrl' evaluated to an empty value.")
return evaluated
def _get_tool_name(self, state: DeclarativeWorkflowState) -> str:
raw = self._action_def.get("toolName")
if raw is None:
raise ValueError("InvokeMcpTool requires a 'toolName' field.")
evaluated = state.eval_if_expression(raw)
if not isinstance(evaluated, str) or not evaluated:
raise ValueError("InvokeMcpTool 'toolName' evaluated to an empty value.")
return evaluated
def _get_server_label(self, state: DeclarativeWorkflowState) -> str | None:
raw = self._action_def.get("serverLabel")
if raw is None:
return None
evaluated = state.eval_if_expression(raw)
if evaluated is None:
return None
text = str(evaluated)
return text or None
def _get_arguments(self, state: DeclarativeWorkflowState) -> dict[str, Any]:
"""Evaluate ``arguments`` map. Preserves ``None`` values (parity with .NET)."""
raw = self._action_def.get("arguments")
if raw is None:
return {}
if not isinstance(raw, Mapping) or not raw:
return {}
result: dict[str, Any] = {}
for key, value in raw.items(): # type: ignore[reportUnknownVariableType]
if not isinstance(key, str) or not key:
continue
result[key] = state.eval_if_expression(value)
return result
def _get_headers(self, state: DeclarativeWorkflowState) -> dict[str, str]:
return self._evaluate_headers(state, self._action_def.get("headers"))
@staticmethod
def _evaluate_headers(state: DeclarativeWorkflowState, headers_def: Any) -> dict[str, str]:
"""Evaluate the ``headers`` map. Empty string values are skipped."""
if not isinstance(headers_def, Mapping) or not headers_def:
return {}
result: dict[str, str] = {}
for key, value in headers_def.items(): # type: ignore[reportUnknownVariableType]
if not isinstance(key, str) or not key:
continue
evaluated = state.eval_if_expression(value)
if evaluated is None:
continue
text = str(evaluated)
if not text:
continue
result[key] = text
return result
def _get_connection_name(self, state: DeclarativeWorkflowState) -> str | None:
connection = self._action_def.get("connection")
if not isinstance(connection, Mapping):
return None
name_expr: Any = connection.get("name") # type: ignore[reportUnknownMemberType]
if name_expr is None:
return None
evaluated = state.eval_if_expression(name_expr)
if evaluated is None:
return None
text = str(evaluated)
return text or None
def _get_require_approval(self, state: DeclarativeWorkflowState) -> bool:
raw = self._action_def.get("requireApproval")
if raw is None:
return False
evaluated = state.eval_if_expression(raw)
if isinstance(evaluated, bool):
return evaluated
if isinstance(evaluated, str):
return evaluated.strip().lower() in {"true", "1", "yes"}
return bool(evaluated)
def _get_auto_send(self, state: DeclarativeWorkflowState) -> bool:
output: Any = self._action_def.get("output")
if not isinstance(output, Mapping):
return True
raw: Any = output.get("autoSend") # type: ignore[reportUnknownMemberType]
if raw is None:
return True
evaluated = state.eval_if_expression(raw)
if isinstance(evaluated, bool):
return evaluated
if isinstance(evaluated, str):
return evaluated.strip().lower() in {"true", "1", "yes"}
return bool(evaluated)
# ----- Invocation + error handling ----------------------------------------
async def _invoke_with_narrow_catch(self, invocation: MCPToolInvocation) -> MCPToolResult:
"""Invoke the handler with a narrow exception catch.
Only known transport / tool exceptions are normalised to an error
result. Programmer bugs (TypeError, ValueError from misuse, etc.)
propagate so they fail loudly.
``asyncio.CancelledError`` is a ``BaseException``, not ``Exception``,
so it is not caught here and propagates unchanged for workflow
cancellation.
"""
try:
return await self._mcp_tool_handler.invoke_tool(invocation)
except ToolExecutionException as exc:
message = str(exc) or type(exc).__name__
return MCPToolResult(
outputs=[Content.from_text(f"Error: {message}")],
is_error=True,
error_message=message,
)
except httpx.HTTPError as exc:
message = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
return MCPToolResult(
outputs=[Content.from_text(f"Error: {message}")],
is_error=True,
error_message=message,
)
except Exception as exc:
try:
from mcp.shared.exceptions import McpError
except ImportError: # pragma: no cover - mcp is a hard dep
raise
if isinstance(exc, McpError):
message = str(exc) or type(exc).__name__
return MCPToolResult(
outputs=[Content.from_text(f"Error: {message}")],
is_error=True,
error_message=message,
)
raise
# ----- Result handling -----------------------------------------------------
async def _process_result(
self,
*,
ctx: WorkflowContext[ActionComplete, str],
state: DeclarativeWorkflowState,
result: MCPToolResult,
auto_send: bool,
conversation_id: str | None,
output_messages_path: str | None,
output_result_path: str | None,
) -> None:
"""Apply ``result`` to workflow state per the configured output paths."""
if result.is_error:
# Error path mirrors .NET ``AssignErrorAsync`` — only the result
# path is touched; messages / autoSend / conversation are not.
self._assign_error(
state,
output_result_path,
result.error_message or "MCP tool invocation failed.",
)
return
parsed_results = _parse_outputs(result.outputs)
if output_result_path is not None and parsed_results:
state.set(output_result_path, parsed_results)
# Single Tool-role message (matches .NET line 178 contract). Differs
# from InvokeFunctionTool's two-message [assistant call, tool result]
# convention.
tool_message = Message(role="tool", contents=list(result.outputs))
if output_messages_path is not None:
state.set(output_messages_path, tool_message)
if auto_send and parsed_results:
await ctx.yield_output(_format_outputs_for_send(parsed_results))
if conversation_id:
messages_path = f"System.conversations.{conversation_id}.messages"
assistant_message = Message(role="assistant", contents=list(result.outputs))
state.append(messages_path, assistant_message)
@staticmethod
def _assign_error(
state: DeclarativeWorkflowState,
output_result_path: str | None,
error_message: str,
) -> None:
"""Mirror .NET ``AssignErrorAsync``: store ``"Error: <msg>"`` at the result path."""
if output_result_path is None:
return
state.set(output_result_path, f"Error: {error_message}")
def _parse_outputs(outputs: list[Content]) -> list[Any]:
"""Parse :class:`Content` outputs into Python values for ``output.result``.
Mirrors .NET ``AssignResultAsync``:
- ``TextContent`` → JSON-parse text; on failure use the raw text.
- ``DataContent`` / ``UriContent`` → ``content.uri``.
- Other content kinds → ``str(content)``.
"""
parsed: list[Any] = []
for content in outputs:
kind = getattr(content, "type", None)
if kind == "text":
text_value = getattr(content, "text", None)
text_str = "" if text_value is None else str(text_value)
try:
parsed.append(json.loads(text_str))
except (json.JSONDecodeError, ValueError):
parsed.append(text_str)
continue
if kind in ("data", "uri"):
uri_value = getattr(content, "uri", None)
parsed.append("" if uri_value is None else str(uri_value))
continue
parsed.append(str(content))
return parsed
MCP_ACTION_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
"InvokeMcpTool": InvokeMcpToolActionExecutor,
}
@@ -0,0 +1,660 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tool invocation executors for declarative workflows.
Provides base abstractions and concrete executors for invoking various tool types
(functions, APIs, MCP servers, etc.) with support for approval flows and structured output.
This module is designed for extensibility:
- BaseToolExecutor provides common patterns (registry lookup, approval flow, output formatting)
- Concrete executors (InvokeFunctionToolExecutor) implement tool-specific invocation logic
- New tool types can be added by subclassing BaseToolExecutor
"""
import json
import logging
import uuid
from abc import abstractmethod
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from inspect import isawaitable
from typing import Any, cast
from agent_framework import (
Content,
Message,
WorkflowContext,
handler,
response_handler,
)
from ._declarative_base import (
ActionComplete,
DeclarativeActionExecutor,
DeclarativeWorkflowState,
)
from ._executors_agents import TOOL_REGISTRY_KEY
logger = logging.getLogger(__name__)
# Registry key for function tools in State - reuse existing key so functions registered
# at runtime are discoverable by both agent-based and function-based tool executors.
FUNCTION_TOOL_REGISTRY_KEY = TOOL_REGISTRY_KEY
# ============================================================================
# Request/Response Types for Approval Flow
# ============================================================================
@dataclass
class ToolApprovalRequest:
"""Request for approval before invoking a tool.
Emitted when requireApproval=true, signaling that the workflow should yield
and wait for user approval before invoking the tool.
This follows the same pattern as AgentExternalInputRequest from _executors_agents.py,
allowing consistent handling of human-in-loop scenarios across agents and tools.
Attributes:
request_id: Unique identifier for this approval request.
function_name: Evaluated function name to be invoked.
arguments: Evaluated arguments to be passed to the function.
"""
request_id: str
function_name: str
arguments: dict[str, Any]
@dataclass
class ToolApprovalResponse:
"""Response to a ToolApprovalRequest.
Provided by the caller to approve or reject tool invocation.
Attributes:
approved: Whether the tool invocation was approved.
reason: Optional reason for rejection.
"""
approved: bool
reason: str | None = None
# ============================================================================
# Result Types
# ============================================================================
@dataclass
class ToolInvocationResult:
"""Result from a tool invocation.
Attributes:
success: Whether the invocation succeeded.
result: The return value from the tool (if successful).
error: Error message (if failed).
messages: Message list format for conversation history.
rejected: Whether the invocation was rejected during approval.
rejection_reason: Reason for rejection.
"""
success: bool
result: Any = None
error: str | None = None
messages: list[Message] = field(default_factory=cast(Callable[..., list[Message]], list))
rejected: bool = False
rejection_reason: str | None = None
# ============================================================================
# Helper Functions
# ============================================================================
def _normalize_variable_path(variable: str) -> str:
"""Normalize variable names to ensure they have a scope prefix.
Args:
variable: Variable name like 'Local.X' or 'weatherResult'
Returns:
The variable path with a scope prefix (defaults to Local if none provided)
"""
if variable.startswith(("Local.", "System.", "Workflow.", "Agent.", "Conversation.")):
return variable
if "." in variable:
return variable
return "Local." + variable
# ============================================================================
# Base Tool Executor (Abstract)
# ============================================================================
class BaseToolExecutor(DeclarativeActionExecutor):
"""Base class for tool invocation executors.
Provides common functionality for all tool-like executors:
- Tool registry lookup (State + WorkflowFactory registration)
- Approval flow (request_info pattern with yield/resume)
- Output formatting (messages as Message list + result variable)
- Error handling (stores error in output, doesn't raise)
Subclasses must implement:
- _invoke_tool(): Perform the actual tool invocation
YAML Schema (common fields):
kind: <ToolKind>
id: unique_id
functionName: function_to_call # required, supports =expression syntax
requireApproval: true # optional, default=false
arguments: # optional dictionary
param1: value1
param2: =Local.dynamicValue
output:
messages: Local.toolCallMessages # Message list
result: Local.toolResult
autoSend: true # optional, default=true
"""
def __init__(
self,
action_def: dict[str, Any],
*,
id: str | None = None,
tools: dict[str, Any] | None = None,
):
"""Initialize the tool executor.
Args:
action_def: The action definition from YAML
id: Optional executor ID
tools: Registry of tool instances by name (from WorkflowFactory)
"""
super().__init__(action_def, id=id)
self._tools = tools or {}
@abstractmethod
async def _invoke_tool(
self,
tool: Any,
function_name: str,
arguments: dict[str, Any],
state: DeclarativeWorkflowState,
) -> Any:
"""Invoke the tool with the given arguments.
Args:
tool: The tool instance to invoke
function_name: Function/method name to call
arguments: Arguments to pass
state: Workflow state
Returns:
The result from the tool invocation
Raises:
Any exception from the tool invocation
"""
pass
def _get_tool(
self,
function_name: str,
ctx: WorkflowContext[Any, Any],
) -> Any | None:
"""Get tool from registry.
Checks both WorkflowFactory registry (self._tools) and State registry.
Args:
function_name: Name of the function
ctx: Workflow context
Returns:
The tool/function, or None if not found
"""
# Check WorkflowFactory registry first (passed in constructor)
tool = self._tools.get(function_name)
if tool is not None:
return tool
# Check State registry (for runtime registration)
try:
tool_registry: dict[str, Any] | None = ctx.state.get(FUNCTION_TOOL_REGISTRY_KEY)
if tool_registry:
return tool_registry.get(function_name)
except KeyError:
logger.debug(
"%s: tool registry key '%s' not found in state "
"(this is normal if tools are only registered via WorkflowFactory)",
self.__class__.__name__,
FUNCTION_TOOL_REGISTRY_KEY,
)
return None
def _get_output_config(self) -> tuple[str | None, str | None, bool]:
"""Parse output configuration from action definition.
Returns:
Tuple of (messages_var, result_var, auto_send)
"""
output_config: dict[str, str | bool] = self._action_def.get("output", {})
if not isinstance(output_config, Mapping):
return None, None, True
messages_var = output_config.get("messages")
result_var = output_config.get("result")
auto_send = bool(output_config.get("autoSend", True))
return (
str(messages_var) if messages_var else None,
str(result_var) if result_var else None,
auto_send,
)
def _store_result(
self,
result: ToolInvocationResult,
state: DeclarativeWorkflowState,
messages_var: str | None,
result_var: str | None,
) -> None:
"""Store tool invocation result in workflow state.
Args:
result: The tool invocation result
state: Workflow state
messages_var: Variable path for messages output
result_var: Variable path for result output
"""
# Store messages if variable specified
if messages_var:
path = _normalize_variable_path(messages_var)
state.set(path, result.messages)
# Store result if variable specified
if result_var:
path = _normalize_variable_path(result_var)
if result.rejected:
state.set(
path,
{
"approved": False,
"rejected": True,
"reason": result.rejection_reason,
},
)
elif result.success:
state.set(path, result.result)
else:
state.set(
path,
{
"error": result.error,
},
)
async def _format_messages(
self,
function_name: str,
arguments: dict[str, Any],
result: Any,
) -> list[Message]:
"""Format tool invocation as Message list.
Creates tool call + tool result message pair for conversation history,
following the same format as agent tool calls.
Args:
function_name: Function name invoked
arguments: Arguments passed
result: Result from invocation
Returns:
List of Message objects [tool_call_message, tool_result_message]
"""
call_id = str(uuid.uuid4())
# Safely serialize arguments to JSON
try:
arguments_str = json.dumps(arguments) if isinstance(arguments, dict) else str(arguments)
except (TypeError, ValueError) as e:
logger.warning(f"Failed to serialize arguments to JSON: {e}")
arguments_str = str(arguments)
# Tool call message (from assistant)
tool_call_content = Content.from_function_call(
call_id=call_id,
name=function_name,
arguments=arguments_str,
)
tool_call_message = Message(
role="assistant",
contents=[tool_call_content],
)
# Safely serialize result to JSON
try:
result_str = json.dumps(result) if not isinstance(result, str) else result
except (TypeError, ValueError) as e:
logger.warning(f"Failed to serialize result to JSON: {e}")
result_str = str(result)
tool_result_content = Content.from_function_result(
call_id=call_id,
result=result_str,
)
tool_result_message = Message(
role="tool",
contents=[tool_result_content],
)
return [tool_call_message, tool_result_message]
async def _execute_tool_invocation(
self,
function_name: str,
arguments: dict[str, Any],
state: DeclarativeWorkflowState,
ctx: WorkflowContext[Any, Any],
) -> ToolInvocationResult:
"""Execute the tool invocation.
Args:
function_name: Function to invoke
arguments: Arguments to pass
state: Workflow state
ctx: Workflow context
Returns:
ToolInvocationResult with outcome
"""
# Get tool from registry
tool = self._get_tool(function_name, ctx)
if tool is None:
error_msg = f"Function '{function_name}' not found in registry"
logger.error(f"{self.__class__.__name__}: {error_msg}")
return ToolInvocationResult(
success=False,
error=error_msg,
)
try:
# Invoke the tool (subclass implements this)
result_value = await self._invoke_tool(
tool=tool,
function_name=function_name,
arguments=arguments,
state=state,
)
# Format as messages for conversation history
messages = await self._format_messages(
function_name=function_name,
arguments=arguments,
result=result_value,
)
return ToolInvocationResult(
success=True,
result=result_value,
messages=messages,
)
except Exception as e:
logger.error(
"%s: error invoking function '%s': %s: %s",
self.__class__.__name__,
function_name,
type(e).__name__,
e,
exc_info=True,
)
return ToolInvocationResult(
success=False,
error=f"{type(e).__name__}: {e}",
)
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete, str],
) -> None:
"""Handle the tool invocation with optional approval flow.
When requireApproval=true:
1. Saves invocation state to State (keyed by executor ID)
2. Emits ToolApprovalRequest via ctx.request_info()
3. Workflow yields (returns without ActionComplete)
4. Resumes in handle_approval_response() when user responds
"""
state = await self._ensure_state_initialized(ctx, trigger)
# Parse output configuration early so we can store errors
messages_var, result_var, auto_send = self._get_output_config()
# Get and evaluate function name (required)
function_name_expr = self._action_def.get("functionName")
if not function_name_expr:
error_msg = f"Action '{self.id}' is missing required 'functionName' field"
logger.error(f"{self.__class__.__name__}: {error_msg}")
if result_var:
state.set(_normalize_variable_path(result_var), {"error": error_msg})
await ctx.send_message(ActionComplete())
return
function_name = state.eval_if_expression(function_name_expr)
if not function_name:
error_msg = f"Action '{self.id}': functionName expression evaluated to empty"
logger.error(f"{self.__class__.__name__}: {error_msg}")
if result_var:
state.set(_normalize_variable_path(result_var), {"error": error_msg})
await ctx.send_message(ActionComplete())
return
function_name = str(function_name)
# Evaluate arguments
arguments_def = self._action_def.get("arguments", {})
arguments: dict[str, Any] = {}
if arguments_def is not None and not isinstance(arguments_def, dict):
logger.warning(
"%s: 'arguments' must be a dictionary, got %s - ignoring",
self.__class__.__name__,
type(arguments_def).__name__,
)
elif isinstance(arguments_def, dict):
for key, value in arguments_def.items(): # type: ignore[reportUnknownVariableType]
arguments[key] = state.eval_if_expression(value)
# Check if approval is required
require_approval = self._action_def.get("requireApproval", False)
if require_approval:
# Emit approval request - the request payload is the source of
# truth for resumed invocation; no side-channel state is written.
request_id = str(uuid.uuid4())
request = ToolApprovalRequest(
request_id=request_id,
function_name=function_name,
arguments=arguments,
)
logger.info(f"{self.__class__.__name__}: requesting approval for '{function_name}'")
await ctx.request_info(request, ToolApprovalResponse, request_id=request_id)
# Workflow yields - will resume in handle_approval_response
return
# No approval required - invoke directly
result = await self._execute_tool_invocation(
function_name=function_name,
arguments=arguments,
state=state,
ctx=ctx,
)
self._store_result(result, state, messages_var, result_var)
if auto_send and result.success and result.result is not None:
await ctx.yield_output(str(result.result))
await ctx.send_message(ActionComplete())
@response_handler
async def handle_approval_response(
self,
original_request: ToolApprovalRequest,
response: ToolApprovalResponse,
ctx: WorkflowContext[ActionComplete, str],
) -> None:
"""Handle response to a ToolApprovalRequest.
Resumes after the workflow yielded for approval. The invocation
``function_name`` and ``arguments`` are sourced from
``original_request`` (the payload the reviewer approved); output
configuration is re-derived from the executor's action definition.
"""
state = self._get_state(ctx.state)
function_name = original_request.function_name
arguments = original_request.arguments
messages_var, result_var, auto_send = self._get_output_config()
# Check if approved
if not response.approved:
logger.info(f"{self.__class__.__name__}: tool invocation rejected: {response.reason}")
# Store rejection status (don't raise error)
result = ToolInvocationResult(
success=False,
rejected=True,
rejection_reason=response.reason,
messages=[
Message(
role="assistant",
contents=[
f"Function '{function_name}' was rejected: {response.reason or 'No reason provided'}"
],
)
],
)
self._store_result(result, state, messages_var, result_var)
await ctx.send_message(ActionComplete())
return
# Approved - execute the invocation
result = await self._execute_tool_invocation(
function_name=function_name,
arguments=arguments,
state=state,
ctx=ctx,
)
self._store_result(result, state, messages_var, result_var)
if auto_send and result.success and result.result is not None:
await ctx.yield_output(str(result.result))
await ctx.send_message(ActionComplete())
# ============================================================================
# Function Tool Executor (Concrete)
# ============================================================================
class InvokeFunctionToolExecutor(BaseToolExecutor):
"""Executor that invokes a Python function as a tool.
This executor supports invoking registered Python functions with:
- Expression evaluation for functionName and arguments
- Optional approval flow (yield/resume pattern)
- Async function support
- Message list output for conversation history
YAML Schema:
kind: InvokeFunctionTool
id: invoke_function_example
functionName: get_weather # required, supports =expression syntax
requireApproval: true # optional, default=false
arguments: # optional dictionary
location: =Local.location
unit: F
output:
messages: Local.weatherToolCallItems # Message list
result: Local.WeatherInfo
autoSend: true # optional, default=true
Tool Registration:
Tools can be registered via:
1. WorkflowFactory.register_tool("name", func) - preferred
2. Setting FUNCTION_TOOL_REGISTRY_KEY in State at runtime
Examples:
.. code-block:: python
from agent_framework_declarative import WorkflowFactory
def get_weather(location: str, unit: str = "F") -> dict:
return {"temp": 72, "unit": unit, "location": location}
async def fetch_data(url: str) -> dict:
# async function example
return {"data": "..."}
factory = (
WorkflowFactory().register_tool("get_weather", get_weather).register_tool("fetch_data", fetch_data)
)
workflow = factory.create_workflow_from_yaml_path("workflow.yaml")
"""
async def _invoke_tool(
self,
tool: Any,
function_name: str,
arguments: dict[str, Any],
state: DeclarativeWorkflowState,
) -> Any:
"""Invoke the function tool.
Supports:
- Direct callable functions
- Async functions (via inspect.isawaitable)
Args:
tool: The tool/function to invoke
function_name: Name of the function (for error messages)
arguments: Arguments to pass to the function
state: Workflow state (not used for function tools)
Returns:
The result from the function invocation
Raises:
ValueError: If the tool is not callable
"""
if not callable(tool):
raise ValueError(f"Function '{function_name}' is not callable")
# Invoke the function
result = tool(**arguments)
# Handle async functions
if isawaitable(result):
result = await result
return result
# ============================================================================
# Executor Registry Export
# ============================================================================
TOOL_ACTION_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
"InvokeFunctionTool": InvokeFunctionToolExecutor,
}
@@ -0,0 +1,808 @@
# Copyright (c) Microsoft. All rights reserved.
"""WorkflowFactory creates executable Workflow objects from YAML definitions.
This module provides the main entry point for declarative workflow support,
parsing YAML workflow definitions and creating Workflow objects that can be
executed using the core workflow runtime.
Each YAML action becomes a real Executor node in the workflow graph,
enabling checkpointing, visualization, and pause/resume capabilities.
"""
from __future__ import annotations
import logging
from collections.abc import Mapping
from pathlib import Path
from typing import Any, cast
import yaml
from agent_framework import (
AgentExecutor,
CheckpointStorage,
SupportsAgentRun,
Workflow,
)
from .._loader import AgentFactory
from ._declarative_base import DeclarativeEnvConfig, discover_env_references
from ._declarative_builder import DeclarativeWorkflowBuilder
from ._errors import DeclarativeWorkflowError
from ._http_handler import HttpRequestHandler
from ._mcp_handler import MCPToolHandler
logger = logging.getLogger("agent_framework.declarative")
__all__ = ["WorkflowFactory"]
class WorkflowFactory:
"""Factory for creating executable Workflow objects from YAML definitions.
WorkflowFactory parses declarative workflow YAML files and creates
Workflow objects that can be executed using the core workflow runtime.
Each YAML action becomes a real Executor node in the workflow graph,
enabling checkpointing at action boundaries, visualization, and pause/resume.
Examples:
.. code-block:: python
from agent_framework.declarative import WorkflowFactory
# Basic usage: create workflow from YAML file
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml_path("workflow.yaml")
async for event in workflow.run({"query": "Hello"}, stream=True):
print(event)
.. code-block:: python
from agent_framework.declarative import WorkflowFactory
from agent_framework import FileCheckpointStorage
# With checkpointing for pause/resume support
storage = FileCheckpointStorage(path="./checkpoints")
factory = WorkflowFactory(checkpoint_storage=storage)
workflow = factory.create_workflow_from_yaml_path("workflow.yaml")
.. code-block:: python
from agent_framework.openai import OpenAIChatClient
from agent_framework.declarative import WorkflowFactory
# Pre-register agents for InvokeAzureAgent actions
client = OpenAIChatClient()
agent = client.as_agent(name="MyAgent", instructions="You are helpful.")
factory = WorkflowFactory(agents={"MyAgent": agent})
workflow = factory.create_workflow_from_yaml_path("workflow.yaml")
"""
_agents: dict[str, SupportsAgentRun | AgentExecutor]
def __init__(
self,
*,
agent_factory: AgentFactory | None = None,
agents: Mapping[str, SupportsAgentRun | AgentExecutor] | None = None,
bindings: Mapping[str, Any] | None = None,
env_file: str | None = None,
checkpoint_storage: CheckpointStorage | None = None,
max_iterations: int | None = None,
http_request_handler: HttpRequestHandler | None = None,
mcp_tool_handler: MCPToolHandler | None = None,
configuration: Mapping[str, str] | None = None,
restrict_env_to_configuration: bool = True,
) -> None:
"""Initialize the workflow factory.
Args:
agent_factory: Optional AgentFactory for creating agents from inline YAML definitions.
agents: Optional pre-created agents by name. These are looked up when processing
InvokeAzureAgent actions in the workflow YAML.
bindings: Optional function bindings for tool calls within workflow actions.
env_file: Optional path to .env file for environment variables used in agent creation.
checkpoint_storage: Optional checkpoint storage enabling pause/resume functionality.
max_iterations: Optional maximum runner supersteps. Overrides the YAML ``maxTurns``
field and the core default (100). Workflows with ``GotoAction`` loops (e.g.
DeepResearch) typically need a higher value.
http_request_handler: Optional handler used to dispatch HTTP requests for
``HttpRequestAction``. Required if the workflow contains any
``HttpRequestAction``; build will fail with :class:`DeclarativeWorkflowError`
otherwise. Use :class:`agent_framework.declarative.DefaultHttpRequestHandler`
for a no-policy ``httpx``-based default, or supply your own implementation
to enforce SSRF guards, allowlisting, or auth resolution.
mcp_tool_handler: Optional handler used to dispatch MCP tool calls for
``InvokeMcpTool``. Required if the workflow contains any
``InvokeMcpTool``; build will fail with :class:`DeclarativeWorkflowError`
otherwise. Use :class:`agent_framework.declarative.DefaultMCPToolHandler`
for a default backed by :class:`agent_framework.MCPStreamableHTTPTool`,
or supply your own implementation to enforce SSRF guards, allowlisting,
or auth/connection resolution.
configuration: Optional mapping that populates the PowerFx ``Env``
symbol referenced from workflow YAML expressions (e.g.
``=Env.MY_KEY``). Keys supplied here are always exposed
under ``Env.<key>``; the process ``os.environ`` is consulted
only when ``restrict_env_to_configuration`` is ``False``.
When neither source produces a value the ``Env`` symbol is
omitted so ``=Env.X`` evaluates to the literal expression
string.
restrict_env_to_configuration: When ``True`` (default), the
``Env`` PowerFx symbol is populated exclusively from
``configuration``; ``os.environ`` is never consulted. Set to
``False`` to additionally fall back to ``os.environ`` for
names absent from ``configuration`` that the workflow YAML
explicitly references. The fallback is constrained to names
discovered in PowerFx expressions inside the workflow
definition so unrelated environment variables never enter
the PowerFx scope.
Examples:
.. code-block:: python
from agent_framework.declarative import WorkflowFactory
# Minimal initialization
factory = WorkflowFactory()
.. code-block:: python
from agent_framework.openai import OpenAIChatClient
from agent_framework.declarative import WorkflowFactory
# With pre-registered agents
client = OpenAIChatClient()
agents = {
"WriterAgent": client.as_agent(name="Writer", instructions="Write content."),
"ReviewerAgent": client.as_agent(name="Reviewer", instructions="Review content."),
}
factory = WorkflowFactory(agents=agents)
.. code-block:: python
from agent_framework import FileCheckpointStorage
from agent_framework.declarative import WorkflowFactory
# With checkpoint storage for pause/resume
factory = WorkflowFactory(
checkpoint_storage=FileCheckpointStorage("./checkpoints"),
env_file=".env",
)
.. code-block:: python
from agent_framework.declarative import WorkflowFactory
# Inject named values for =Env.* references in the workflow YAML
factory = WorkflowFactory(
configuration={
"MY_SERVER_URL": "https://example.com",
"MY_TOOL_NAME": "search",
},
)
"""
self._agent_factory = agent_factory or AgentFactory(env_file_path=env_file)
self._agents: dict[str, SupportsAgentRun | AgentExecutor] = dict(agents) if agents else {}
self._bindings: dict[str, Any] = dict(bindings) if bindings else {}
self._tools: dict[str, Any] = {} # Tool registry for InvokeFunctionTool actions
self._checkpoint_storage = checkpoint_storage
self._max_iterations = max_iterations
self._http_request_handler = http_request_handler
self._mcp_tool_handler = mcp_tool_handler
self._configuration: dict[str, str] = dict(configuration) if configuration else {}
self._restrict_env_to_configuration = restrict_env_to_configuration
def create_workflow_from_yaml_path(
self,
yaml_path: str | Path,
) -> Workflow:
"""Create a Workflow from a YAML file path.
Args:
yaml_path: Path to the YAML workflow definition file.
Returns:
An executable Workflow object with action nodes for each YAML action.
Raises:
DeclarativeWorkflowError: If the YAML is invalid or cannot be parsed.
FileNotFoundError: If the YAML file doesn't exist.
Examples:
.. code-block:: python
from agent_framework.declarative import WorkflowFactory
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml_path("workflow.yaml")
# Execute the workflow
async for event in workflow.run({"input": "Hello"}, stream=True):
print(event)
.. code-block:: python
from pathlib import Path
from agent_framework.declarative import WorkflowFactory
# Using Path object
workflow_path = Path(__file__).parent / "workflows" / "customer_support.yaml"
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml_path(workflow_path)
"""
if not isinstance(yaml_path, Path):
yaml_path = Path(yaml_path)
if not yaml_path.exists():
raise FileNotFoundError(f"Workflow YAML file not found: {yaml_path}")
with open(yaml_path) as f:
yaml_content = f.read()
return self.create_workflow_from_yaml(yaml_content, base_path=yaml_path.parent)
def create_workflow_from_yaml(
self,
yaml_content: str,
base_path: Path | None = None,
) -> Workflow:
"""Create a Workflow from a YAML string.
Args:
yaml_content: The YAML workflow definition as a string.
base_path: Optional base path for resolving relative file references
in agent definitions.
Returns:
An executable Workflow object with action nodes for each YAML action.
Raises:
DeclarativeWorkflowError: If the YAML is invalid or cannot be parsed.
Examples:
.. code-block:: python
from agent_framework.declarative import WorkflowFactory
yaml_content = '''
kind: Workflow
trigger:
kind: OnConversationStart
id: greeting_workflow
actions:
- kind: SetVariable
id: set_greeting
variable: Local.Greeting
value: "Hello, World!"
- kind: SendActivity
id: send_greeting
activity: =Local.Greeting
'''
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml(yaml_content)
.. code-block:: python
from pathlib import Path
from agent_framework.declarative import WorkflowFactory
# With base_path for resolving relative agent file references
yaml_content = '''
kind: Workflow
agents:
MyAgent:
file: ./agents/my_agent.yaml
trigger:
actions:
- kind: InvokeAzureAgent
agent:
name: MyAgent
'''
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml(
yaml_content,
base_path=Path("./workflows"),
)
"""
try:
workflow_def = yaml.safe_load(yaml_content)
except yaml.YAMLError as e:
raise DeclarativeWorkflowError(f"Invalid YAML: {e}") from e
return self.create_workflow_from_definition(workflow_def, base_path=base_path)
def create_workflow_from_definition(
self,
workflow_def: dict[str, Any],
base_path: Path | None = None,
) -> Workflow:
"""Create a Workflow from a parsed workflow definition dictionary.
This is the lowest-level creation method, useful when you already have
a parsed dictionary (e.g., from programmatic construction or custom parsing).
Args:
workflow_def: The parsed workflow definition dictionary containing
'kind', 'trigger', 'actions', and optionally 'agents' keys.
base_path: Optional base path for resolving relative file references
in agent definitions.
Returns:
An executable Workflow object with action nodes for each YAML action.
Raises:
DeclarativeWorkflowError: If the definition is invalid or missing required fields.
Examples:
.. code-block:: python
from agent_framework.declarative import WorkflowFactory
# Programmatically construct a workflow definition
workflow_def = {
"kind": "Workflow",
"name": "my_workflow",
"trigger": {
"kind": "OnConversationStart",
"id": "main_trigger",
"actions": [
{
"kind": "SetVariable",
"id": "init",
"variable": "Local.Counter",
"value": 0,
},
{
"kind": "SendActivity",
"id": "output",
"activity": "Counter initialized",
},
],
},
}
factory = WorkflowFactory()
workflow = factory.create_workflow_from_definition(workflow_def)
"""
# Validate the workflow definition
self._validate_workflow_def(workflow_def)
# Extract workflow metadata
# Support both "name" field and trigger.id for workflow name
name: str = workflow_def.get("name", "")
if not name:
trigger: dict[str, Any] = workflow_def.get("trigger", {})
trigger_id = trigger.get("id", "declarative_workflow")
name = str(trigger_id) if trigger_id else "declarative_workflow"
description = workflow_def.get("description")
# Create agents from definitions
agents: dict[str, SupportsAgentRun | AgentExecutor] = dict(self._agents)
agent_defs = workflow_def.get("agents", {})
for agent_name, agent_def in agent_defs.items():
if agent_name in agents:
# Already have this agent
continue
# Create agent using AgentFactory
try:
agent = self._create_agent_from_def(agent_def, base_path)
agents[agent_name] = agent
logger.debug(f"Created agent '{agent_name}' from definition")
except Exception as e:
logger.error(f"Failed to create agent '{agent_name}': {e}")
raise DeclarativeWorkflowError(f"Failed to create agent '{agent_name}': {e}") from e
return self._create_workflow(workflow_def, name, description, agents)
def _create_workflow(
self,
workflow_def: dict[str, Any],
name: str,
description: str | None,
agents: dict[str, SupportsAgentRun | AgentExecutor],
) -> Workflow:
"""Create workflow from definition.
Each YAML action becomes a real Executor node in the workflow graph.
This enables checkpointing at action boundaries.
Args:
workflow_def: The workflow definition
name: Workflow name
description: Workflow description
agents: Registry of agent instances
Returns:
Workflow with individual action executors as nodes
"""
# Normalize workflow definition to have actions at top level
normalized_def = self._normalize_workflow_def(workflow_def)
normalized_def["name"] = name
if description:
normalized_def["description"] = description
# Build the DeclarativeEnvConfig from the factory's configuration and the
# set of Env references actually used in the workflow PowerFx expressions.
# The referenced-name allowlist constrains ``os.environ`` fallback (when
# enabled) so unrelated variables never enter the PowerFx scope.
env_config = DeclarativeEnvConfig(
values=dict(self._configuration),
restrict_to_configuration=self._restrict_env_to_configuration,
referenced_names=frozenset(discover_env_references(normalized_def)),
)
# Build the graph-based workflow, passing agents and tools for specialized executors
try:
graph_builder = DeclarativeWorkflowBuilder(
normalized_def,
workflow_id=name,
agents=agents,
tools=self._tools,
checkpoint_storage=self._checkpoint_storage,
max_iterations=self._max_iterations,
http_request_handler=self._http_request_handler,
mcp_tool_handler=self._mcp_tool_handler,
env_config=env_config,
)
workflow = graph_builder.build()
except ValueError as e:
raise DeclarativeWorkflowError(f"Failed to build graph-based workflow: {e}") from e
# Store agents, bindings, and tools for reference (executors already have them)
workflow._declarative_agents = agents # type: ignore[attr-defined]
workflow._declarative_bindings = self._bindings # type: ignore[attr-defined]
workflow._declarative_tools = self._tools # type: ignore[attr-defined]
# Store input schema if defined in workflow definition
# This allows DevUI to generate proper input forms
if "inputs" in workflow_def:
workflow.input_schema = self._convert_inputs_to_json_schema(workflow_def["inputs"]) # type: ignore[attr-defined]
logger.debug(
"Created graph-based workflow '%s' with %d executors",
name,
len(graph_builder._executors), # type: ignore[reportPrivateUsage]
)
return workflow
def _normalize_workflow_def(self, workflow_def: dict[str, Any]) -> dict[str, Any]:
"""Normalize workflow definition to have actions at top level.
Args:
workflow_def: The workflow definition
Returns:
Normalized definition with actions at top level
"""
actions = self._get_actions_from_def(workflow_def)
return {
**workflow_def,
"actions": actions,
}
def _validate_workflow_def(self, workflow_def: dict[str, Any]) -> None:
"""Validate a workflow definition.
Args:
workflow_def: The workflow definition to validate
Raises:
DeclarativeWorkflowError: If the definition is invalid
"""
if not isinstance(workflow_def, dict):
raise DeclarativeWorkflowError("Workflow definition must be a dictionary")
# Handle both formats:
# 1. Direct actions list: {"actions": [...]}
# 2. Trigger-based: {"kind": "Workflow", "trigger": {"actions": [...]}}
actions = self._get_actions_from_def(workflow_def)
if not isinstance(actions, list):
raise DeclarativeWorkflowError("Workflow 'actions' must be a list")
# Validate each action has a kind
for i, action in enumerate(actions):
if not isinstance(action, dict):
raise DeclarativeWorkflowError(f"Action at index {i} must be a dictionary")
if "kind" not in action:
raise DeclarativeWorkflowError(f"Action at index {i} missing 'kind' field")
def _get_actions_from_def(self, workflow_def: dict[str, Any]) -> list[dict[str, Any]]:
"""Extract actions from a workflow definition.
Handles both direct actions format and trigger-based format.
Args:
workflow_def: The workflow definition
Returns:
List of action definitions
Raises:
DeclarativeWorkflowError: If no actions can be found
"""
# Try direct actions first
if "actions" in workflow_def:
actions: list[dict[str, Any]] = workflow_def["actions"]
return actions
# Try trigger-based format
if "trigger" in workflow_def:
trigger = workflow_def["trigger"]
if isinstance(trigger, dict) and "actions" in trigger:
trigger_actions: list[dict[str, Any]] = list(trigger["actions"]) # type: ignore[arg-type]
return trigger_actions
raise DeclarativeWorkflowError("Workflow definition must have 'actions' field or 'trigger.actions' field")
def _create_agent_from_def(
self,
agent_def: dict[str, Any],
base_path: Path | None = None,
) -> Any:
"""Create an agent from a definition.
Args:
agent_def: The agent definition dictionary
base_path: Optional base path for resolving relative file references
Returns:
An agent instance
"""
# Check if it's a reference to an external file
if "file" in agent_def:
file_path = agent_def["file"]
if base_path and not Path(file_path).is_absolute():
file_path = base_path / file_path
return self._agent_factory.create_agent_from_yaml_path(file_path)
# Check if it's an inline agent definition
if "kind" in agent_def:
return self._agent_factory.create_agent_from_dict(agent_def)
# Handle connection-based agent (like Azure AI agents)
if "connection" in agent_def:
# This would create a hosted agent client
# For now, we'll need the user to provide pre-created agents
raise DeclarativeWorkflowError(
"Connection-based agents must be provided via the 'agents' parameter. "
"Create the agent using the appropriate client and pass it to WorkflowFactory."
)
raise DeclarativeWorkflowError(
f"Invalid agent definition. Expected 'file', 'kind', or 'connection': {agent_def}"
)
def register_agent(self, name: str, agent: SupportsAgentRun | AgentExecutor) -> WorkflowFactory:
"""Register an agent instance with the factory for use in workflows.
Registered agents are available to InvokeAzureAgent actions by name.
This method supports fluent chaining.
Args:
name: The name to register the agent under. Must match the agent name
referenced in InvokeAzureAgent actions.
agent: The agent instance (typically a Agent or similar).
Returns:
Self for method chaining.
Examples:
.. code-block:: python
from agent_framework.openai import OpenAIChatClient
from agent_framework.declarative import WorkflowFactory
client = OpenAIChatClient()
# Method chaining to register multiple agents
factory = (
WorkflowFactory()
.register_agent(
"Writer",
client.as_agent(
name="Writer",
instructions="Write content.",
),
)
.register_agent(
"Reviewer",
client.as_agent(
name="Reviewer",
instructions="Review content.",
),
)
)
workflow = factory.create_workflow_from_yaml_path("workflow.yaml")
"""
self._agents[name] = agent
return self
def register_binding(self, name: str, func: Any) -> WorkflowFactory:
"""Register a function binding with the factory for use in workflow actions.
Bindings allow workflow actions to invoke Python functions by name.
This method supports fluent chaining.
Args:
name: The name to register the function under.
func: The function to bind.
Returns:
Self for method chaining.
Examples:
.. code-block:: python
from agent_framework.declarative import WorkflowFactory
def get_weather(location: str) -> str:
return f"Weather in {location}: Sunny, 72F"
def send_email(to: str, subject: str, body: str) -> bool:
# Send email logic
return True
# Register functions for use in workflow
factory = (
WorkflowFactory()
.register_binding("get_weather", get_weather)
.register_binding("send_email", send_email)
)
workflow = factory.create_workflow_from_yaml_path("workflow.yaml")
"""
if not callable(func):
raise TypeError(f"Expected a callable for binding '{name}', got {type(func).__name__}")
self._bindings[name] = func
return self
def register_tool(self, name: str, func: Any) -> WorkflowFactory:
"""Register a function with the factory for use in InvokeFunctionTool actions.
Registered functions are available to InvokeFunctionTool actions by name via the functionName field.
This method supports fluent chaining.
Args:
name: The name to register the function under. Must match the functionName
referenced in InvokeFunctionTool actions.
func: The function to register (can be sync or async).
Returns:
Self for method chaining.
Examples:
.. code-block:: python
from agent_framework_declarative import WorkflowFactory
def get_weather(location: str, unit: str = "F") -> dict:
return {"temp": 72, "unit": unit, "location": location}
async def fetch_data(url: str) -> dict:
# Async function example
return {"data": "..."}
# Register functions for use in InvokeFunctionTool workflow actions
factory = (
WorkflowFactory().register_tool("get_weather", get_weather).register_tool("fetch_data", fetch_data)
)
workflow = factory.create_workflow_from_yaml_path("workflow.yaml")
The workflow YAML can then reference these tools:
.. code-block:: yaml
actions:
- kind: InvokeFunctionTool
id: call_weather
functionName: get_weather
arguments:
location: =Local.city
unit: F
output:
result: Local.weatherData
"""
if not callable(func):
raise TypeError(f"Expected a callable for tool '{name}', got {type(func).__name__}")
self._tools[name] = func
return self
def _convert_inputs_to_json_schema(self, inputs_def: dict[str, Any]) -> dict[str, Any]:
"""Convert a declarative inputs definition to JSON Schema.
The inputs definition uses a simplified format:
inputs:
age:
type: integer
description: The user's age
name:
type: string
This is converted to standard JSON Schema format.
Args:
inputs_def: The inputs definition from the workflow YAML
Returns:
A JSON Schema object
"""
properties: dict[str, Any] = {}
required: list[str] = []
for field_name, field_def in inputs_def.items():
if isinstance(field_def, dict):
# Field has type and possibly other attributes
prop: dict[str, Any] = {}
field_def_dict: dict[str, Any] = cast(dict[str, Any], field_def)
field_type: str = str(field_def_dict.get("type", "string"))
# Map declarative types to JSON Schema types
type_mapping: dict[str, str] = {
"string": "string",
"str": "string",
"integer": "integer",
"int": "integer",
"number": "number",
"float": "number",
"boolean": "boolean",
"bool": "boolean",
"array": "array",
"list": "array",
"object": "object",
"dict": "object",
}
prop["type"] = type_mapping.get(field_type, field_type)
# Copy other attributes
if "description" in field_def_dict:
prop["description"] = field_def_dict["description"]
if "default" in field_def_dict:
prop["default"] = field_def_dict["default"]
if "enum" in field_def_dict:
prop["enum"] = field_def_dict["enum"]
# Check if required (default: true unless explicitly false)
if field_def_dict.get("required", True):
required.append(field_name)
properties[field_name] = prop
else:
# Simple type definition (e.g., "age: integer")
type_mapping_simple: dict[str, str] = {
"string": "string",
"str": "string",
"integer": "integer",
"int": "integer",
"number": "number",
"float": "number",
"boolean": "boolean",
"bool": "boolean",
}
properties[field_name] = {"type": type_mapping_simple.get(str(field_def), "string")}
required.append(field_name)
schema: dict[str, Any] = {
"type": "object",
"properties": properties,
}
if required:
schema["required"] = required
return schema
@@ -0,0 +1,237 @@
# Copyright (c) Microsoft. All rights reserved.
"""HTTP request handler abstraction for declarative workflows.
Mirrors the .NET ``IHttpRequestHandler`` / ``DefaultHttpRequestHandler`` pair from
``Microsoft.Agents.AI.Workflows.Declarative``. Provides:
- :class:`HttpRequestInfo` — request input data passed from the executor.
- :class:`HttpRequestResult` — response data returned to the executor.
- :class:`HttpRequestHandler` — :class:`typing.Protocol` callers implement to plug
in custom transports (e.g. with allowlisting, mTLS, retries, etc.).
- :class:`DefaultHttpRequestHandler` — production-grade default backed by
``httpx.AsyncClient``.
Security note: :class:`DefaultHttpRequestHandler` performs **no** URL filtering
or SSRF protection. Production deployments should supply a custom handler that
enforces an allowlist or DNS-rebinding-resistant policy. This split mirrors the
.NET design.
"""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass, field
from typing import Any, Protocol, runtime_checkable
import httpx
__all__ = [
"DefaultHttpRequestHandler",
"HttpRequestHandler",
"HttpRequestInfo",
"HttpRequestResult",
]
@dataclass
class HttpRequestInfo:
"""Description of an HTTP request to be dispatched by a :class:`HttpRequestHandler`.
Mirrors the .NET ``HttpRequestInfo`` record. Field semantics:
- ``method``: HTTP method (``GET``, ``POST``, etc.). Already upper-cased by the executor.
- ``url``: Absolute URL. Already evaluated from the YAML expression.
- ``headers``: Single-value header map (case-insensitive keys per HTTP semantics
but stored as authored). Empty values are skipped by the executor.
- ``query_parameters``: String key/value pairs appended to the URL.
- ``body``: Request body bytes/text, or ``None`` for no body.
- ``body_content_type``: Content type to send (e.g. ``application/json``).
Ignored when ``body`` is ``None``.
- ``timeout_ms``: Per-request timeout in milliseconds. ``None`` => use the
handler's default.
- ``connection_name``: Optional Foundry connection name for handlers that
resolve auth/credentials by connection.
"""
method: str
url: str
headers: dict[str, str] = field(default_factory=dict) # type: ignore[reportUnknownVariableType]
query_parameters: dict[str, str] = field(default_factory=dict) # type: ignore[reportUnknownVariableType]
body: str | None = None
body_content_type: str | None = None
timeout_ms: int | None = None
connection_name: str | None = None
@dataclass
class HttpRequestResult:
"""Response returned by a :class:`HttpRequestHandler`.
Mirrors the .NET ``HttpRequestResult`` record. ``headers`` preserves
multi-value response headers (e.g. multiple ``Set-Cookie`` headers) as a
``dict[str, list[str]]``. The executor folds duplicates into a single
comma-joined string only at the point it assigns ``responseHeaders`` to
workflow state.
Header keys are normalized to lowercase so that lookups are consistent
regardless of the server's transmitted casing (HTTP headers are
case-insensitive per RFC 7230 §3.2). Custom :class:`HttpRequestHandler`
implementations should follow the same convention.
"""
status_code: int
is_success_status_code: bool
body: str
headers: dict[str, list[str]] = field(default_factory=dict) # type: ignore[reportUnknownVariableType]
@runtime_checkable
class HttpRequestHandler(Protocol):
"""Protocol for HTTP request handlers used by ``HttpRequestAction``.
Implementations must be safe to call concurrently from multiple workflow
runs. Implementations are responsible for any URL allowlisting, SSRF
guards, retry policies, auth resolution, and other policies that the
workflow author wants applied.
"""
async def send(self, info: HttpRequestInfo) -> HttpRequestResult:
"""Dispatch ``info`` and return the response result.
Args:
info: Description of the request to send.
Returns:
The response. Implementations should NOT raise on non-2xx status
codes; instead, set ``is_success_status_code`` accordingly. They
SHOULD raise on transport-level failures (connection refused,
DNS errors, timeouts).
"""
...
ClientProvider = Callable[[HttpRequestInfo], Awaitable["httpx.AsyncClient | None"]]
class DefaultHttpRequestHandler:
"""Default :class:`HttpRequestHandler` backed by :class:`httpx.AsyncClient`.
Construction modes:
1. ``DefaultHttpRequestHandler()`` — owns an internal client created lazily
on first ``send()``. Closed by :meth:`aclose`.
2. ``DefaultHttpRequestHandler(client=existing)`` — caller-owned client.
Not closed by :meth:`aclose`.
3. ``DefaultHttpRequestHandler(client_provider=cb)`` — per-request client
lookup (parity with .NET's ``httpClientProvider`` callback). The
provider may return ``None`` to fall back to the owned/default client.
.. warning::
This handler performs **no** URL filtering or SSRF protection. Wrap or
replace it with a custom handler in production.
"""
def __init__(
self,
*,
client: httpx.AsyncClient | None = None,
client_provider: ClientProvider | None = None,
) -> None:
self._owned_client: httpx.AsyncClient | None = None
self._caller_client = client
self._client_provider = client_provider
# Guards lazy creation of ``_owned_client`` against concurrent first
# ``send()`` calls leaking duplicate clients.
self._owned_client_lock = asyncio.Lock()
async def send(self, info: HttpRequestInfo) -> HttpRequestResult:
"""Dispatch the request and return the parsed result."""
if not info.url:
raise ValueError("HttpRequestInfo.url must be a non-empty string.")
if not info.method:
raise ValueError("HttpRequestInfo.method must be a non-empty string.")
client = await self._resolve_client(info)
timeout: httpx.Timeout | object
if info.timeout_ms is not None and info.timeout_ms > 0:
timeout = httpx.Timeout(info.timeout_ms / 1000.0)
else:
timeout = httpx.USE_CLIENT_DEFAULT
headers = dict(info.headers)
content: bytes | str | None = None
if info.body is not None:
content = info.body
if not _has_header(headers, "content-type"):
# Match .NET DefaultHttpRequestHandler: when a body is sent
# without an explicit content type, default to ``text/plain``
# so the request is interpretable by servers and direct
# callers (not just the YAML executor) get sensible defaults.
headers["Content-Type"] = info.body_content_type or "text/plain"
params: Mapping[str, str] | None = info.query_parameters or None
response = await client.request(
method=info.method,
url=info.url,
params=params,
headers=headers or None,
content=content,
timeout=timeout,
)
# Preserve multi-value headers (e.g. multiple Set-Cookie) as list[str].
# Normalize names to lowercase so lookups are consistent and case
# variations from the transport do not create duplicate logical keys
# (HTTP headers are case-insensitive per RFC 7230 §3.2).
result_headers: dict[str, list[str]] = {}
for key, value in response.headers.multi_items():
result_headers.setdefault(key.lower(), []).append(value)
body_text = response.text
return HttpRequestResult(
status_code=response.status_code,
is_success_status_code=200 <= response.status_code < 300,
body=body_text,
headers=result_headers,
)
async def aclose(self) -> None:
"""Release the owned client, if any. Caller-owned clients are NOT closed."""
if self._owned_client is not None:
await self._owned_client.aclose()
self._owned_client = None
async def _resolve_client(self, info: HttpRequestInfo) -> httpx.AsyncClient:
"""Pick a client for this request: provider → caller → lazily-owned."""
if self._client_provider is not None:
provided = await self._client_provider(info)
if provided is not None:
return provided
if self._caller_client is not None:
return self._caller_client
if self._owned_client is None:
# Double-checked locking under asyncio.Lock so concurrent first
# callers don't each create a fresh httpx.AsyncClient and orphan
# one of them.
async with self._owned_client_lock:
if self._owned_client is None:
self._owned_client = httpx.AsyncClient()
return self._owned_client
async def __aenter__(self) -> DefaultHttpRequestHandler:
return self
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
await self.aclose()
def _has_header(headers: Mapping[str, str], name: str) -> bool:
"""Case-insensitive header presence check."""
needle = name.lower()
return any(key.lower() == needle for key in headers)
@@ -0,0 +1,581 @@
# Copyright (c) Microsoft. All rights reserved.
"""MCP tool handler abstraction for declarative workflows.
Mirrors the .NET ``IMcpToolHandler`` / ``DefaultMcpToolHandler`` pair from
``Microsoft.Agents.AI.Workflows.Declarative.Mcp``. Provides:
- :class:`MCPToolInvocation` — request input data passed from the executor.
- :class:`MCPToolResult` — response data returned to the executor.
- :class:`MCPToolHandler` — :class:`typing.Protocol` callers implement to plug
in custom transports (e.g. with allowlisting, Foundry connection resolution,
per-server auth, etc.).
- :class:`DefaultMCPToolHandler` — production-grade default backed by
:class:`agent_framework.MCPStreamableHTTPTool`.
Security note: :class:`DefaultMCPToolHandler` performs **no** URL filtering or
SSRF protection. Production deployments should supply a custom handler that
enforces an allowlist or DNS-rebinding-resistant policy. This split mirrors the
.NET design.
Prompt-injection note: MCP tool outputs flow back into agent conversations
(via ``conversationId`` and Tool-role messages emitted by the executor) so
they share the same risk surface as ``HttpRequestAction``. Workflow authors
must trust the MCP server they invoke.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
from collections import OrderedDict
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, ClassVar, Protocol, cast, runtime_checkable
import httpx
if TYPE_CHECKING:
from agent_framework import Content
__all__ = [
"ClientProvider",
"DefaultMCPToolHandler",
"MCPToolHandler",
"MCPToolInvocation",
"MCPToolResult",
]
logger = logging.getLogger(__name__)
_DEFAULT_CACHE_MAX_SIZE = 32
@dataclass
class MCPToolInvocation:
"""Description of an MCP tool call to be dispatched by a :class:`MCPToolHandler`.
Mirrors the input parameters of the .NET ``IMcpToolHandler.InvokeToolAsync``
method. Field semantics:
- ``server_url``: Absolute URL of the MCP server. Already evaluated from
the YAML expression.
- ``server_label``: Optional human-readable label used for diagnostics
and as the underlying ``MCPStreamableHTTPTool`` name.
- ``tool_name``: Name of the tool to invoke on the MCP server.
- ``arguments``: Tool arguments. Already evaluated; values may be any
JSON-serialisable Python object (str, int, bool, dict, list, None).
- ``headers``: Outbound HTTP headers (e.g. authentication). Empty values
are skipped by the executor before construction.
- ``connection_name``: Optional Foundry connection name forwarded for
handlers that resolve auth/credentials by connection. The default
handler does not consume this field.
"""
server_url: str
tool_name: str
server_label: str | None = None
arguments: dict[str, Any] = field(default_factory=dict) # type: ignore[reportUnknownVariableType]
headers: dict[str, str] = field(default_factory=dict) # type: ignore[reportUnknownVariableType]
connection_name: str | None = None
def _empty_outputs() -> list[Any]:
"""Default factory for ``MCPToolResult.outputs``.
Typed as ``list[Any]`` here to keep the dataclass field's runtime
factory simple; the public type on :class:`MCPToolResult` is
``list[Content]``.
"""
return []
@dataclass
class MCPToolResult:
"""Response returned by an :class:`MCPToolHandler`.
Mirrors the .NET ``McpServerToolResultContent`` shape. ``outputs`` is a
list of :class:`agent_framework.Content` items as parsed by the MCP
transport (TextContent / DataContent / UriContent / etc.).
On error, ``is_error`` is ``True``, ``error_message`` carries a human
readable description, and ``outputs`` typically contains a single
``Content.from_text("Error: ...")`` entry for downstream display.
"""
outputs: list[Content] = field(default_factory=_empty_outputs)
is_error: bool = False
error_message: str | None = None
@runtime_checkable
class MCPToolHandler(Protocol):
"""Protocol for MCP tool handlers used by ``InvokeMcpTool``.
Mirrors :class:`HttpRequestHandler` — declares ONLY the invocation method.
Lifecycle methods (``aclose`` / ``__aenter__`` / ``__aexit__``) are NOT
part of the Protocol; concrete implementations may add them as
appropriate.
Implementations must be safe to call concurrently from multiple workflow
runs. Implementations are responsible for any URL allowlisting, SSRF
guards, retry policies, auth resolution, and other policies the workflow
author wants applied.
"""
async def invoke_tool(self, invocation: MCPToolInvocation) -> MCPToolResult:
"""Dispatch ``invocation`` and return the result.
Args:
invocation: Description of the MCP tool call to perform.
Returns:
The :class:`MCPToolResult` carrying the parsed outputs (or an
error flag if the tool raised). Implementations SHOULD return a
result with ``is_error=True`` rather than raising for transport
or tool-level failures, so the workflow can store the message in
``output.result`` (matching .NET ``AssignErrorAsync`` behaviour).
They MAY raise on unexpected programming errors — these will be
propagated unchanged by the executor so they fail loudly.
"""
...
ClientProvider = Callable[[MCPToolInvocation], Awaitable["httpx.AsyncClient | None"]]
@dataclass
class _CacheEntry:
"""Internal record stored in the LRU cache."""
tool: Any # MCPStreamableHTTPTool — typed Any to avoid import at module load
owned_httpx_client: httpx.AsyncClient | None
class DefaultMCPToolHandler:
"""Default :class:`MCPToolHandler` backed by :class:`agent_framework.MCPStreamableHTTPTool`.
Caches one :class:`agent_framework.MCPStreamableHTTPTool` instance per
``(server_url, server_label, connection_name, headers_hash)`` in a
bounded LRU. The cache prevents re-establishing an MCP session for every
invocation while ensuring different header sets (auth tokens) cannot
share a session — matches the .NET design intent while bounding
cardinality. ``server_label`` and ``connection_name`` participate in
the key so that callers using ``client_provider`` to dispatch on those
fields receive a fresh client per logical connection (see below).
Header *names* are lower-cased inside the hash payload only — the
headers passed on the wire keep the caller's original casing — so two
YAML actions that spell ``Authorization`` differently still share a
cache entry.
Construction modes:
1. ``DefaultMCPToolHandler()`` — owns its own ``httpx.AsyncClient``
instances created lazily per cache entry. Closed by :meth:`aclose`.
2. ``DefaultMCPToolHandler(client_provider=cb)`` — per-server client
lookup (parity with .NET ``httpClientProvider`` callback). The
callback receives the full :class:`MCPToolInvocation` so it can
dispatch on ``server_url`` / ``connection_name`` / ``server_label``.
Returning ``None`` falls back to an internally-created client. Caller
supplied clients are NOT closed by :meth:`aclose`.
.. warning::
This handler performs **no** URL filtering or SSRF protection. Wrap
or replace it with a custom handler in production deployments.
Args:
client_provider: Optional per-server ``httpx.AsyncClient`` provider.
cache_max_size: Maximum number of cached MCP clients. When exceeded,
the least-recently-used entry is evicted and its client closed
(only owned clients are closed; caller-supplied ones are not).
Defaults to ``32``.
"""
LIST_TOOLS_TOOL_NAME: ClassVar[str] = "tools/list"
"""Reserved ``tool_name`` that maps an :class:`MCPToolHandler` invocation
to the MCP protocol ``tools/list`` discovery operation.
The constant matches the underlying MCP method name so a single
string travels unchanged through host code, YAML, and the protocol
wire. When this handler receives an invocation with this name it
pages through ``session.list_tools()`` and returns the catalog as a
single ``TextContent`` containing JSON of shape
``{"tools": [{name, description, inputSchema, outputSchema}, ...]}``.
Workflows can reference this name from an ``InvokeMcpTool`` declarative
action to introspect a server's tool surface without an extra round-trip
from host code.
"""
def __init__(
self,
*,
client_provider: ClientProvider | None = None,
cache_max_size: int = _DEFAULT_CACHE_MAX_SIZE,
) -> None:
if cache_max_size <= 0:
raise ValueError(f"cache_max_size must be positive, got {cache_max_size}")
self._client_provider = client_provider
self._cache_max_size = cache_max_size
self._cache: OrderedDict[tuple[str, str, str, str], _CacheEntry] = OrderedDict()
# Outer lock guards the cache + in-flight-future map only — never
# held across network I/O.
self._cache_lock = asyncio.Lock()
# Per-key in-flight futures: while one task is connecting, other
# tasks awaiting the same key will await the same future and share
# the resulting cache entry.
self._inflight: dict[tuple[str, str, str, str], asyncio.Future[_CacheEntry]] = {}
# Set by ``aclose`` to prevent post-close cache insertions and to
# reject new ``invoke_tool`` calls. Once set, never cleared.
self._closed = False
async def invoke_tool(self, invocation: MCPToolInvocation) -> MCPToolResult:
"""Invoke ``invocation.tool_name`` on the cached MCP client for the server.
The reserved name :attr:`LIST_TOOLS_TOOL_NAME` (``"tools/list"``) is
intercepted client-side: instead of being forwarded as a tool call,
it is translated to an MCP ``session.list_tools()`` discovery
operation (paginated automatically) and returned as a single
``TextContent`` containing a JSON tool catalog.
"""
from agent_framework import Content
from agent_framework.exceptions import ToolExecutionException
# Reserved-name args validation runs before connect: rejecting bad
# input shouldn't require establishing an MCP session.
if invocation.tool_name == self.LIST_TOOLS_TOOL_NAME and invocation.arguments:
message = f"The reserved MCP '{self.LIST_TOOLS_TOOL_NAME}' operation does not accept tool arguments."
return MCPToolResult(
outputs=[Content.from_text(f"Error: {message}")],
is_error=True,
error_message=message,
)
try:
entry = await self._get_or_create_entry(invocation)
except Exception as exc:
# Connect / cache lookup failures surface as tool errors so the
# workflow can store them at output.result without crashing.
logger.warning(
"DefaultMCPToolHandler: failed to obtain MCP client for url=%s tool=%s: %s",
invocation.server_url,
invocation.tool_name,
exc,
)
message = f"Failed to connect to MCP server: {type(exc).__name__}: {exc}".rstrip(": ")
return MCPToolResult(
outputs=[Content.from_text(f"Error: {message}")],
is_error=True,
error_message=message,
)
try:
if invocation.tool_name == self.LIST_TOOLS_TOOL_NAME:
return await self._invoke_list_tools(entry)
raw = await entry.tool.call_tool(invocation.tool_name, **invocation.arguments)
except ToolExecutionException as exc:
logger.info(
"DefaultMCPToolHandler: tool '%s' on '%s' raised ToolExecutionException",
invocation.tool_name,
invocation.server_url,
)
message = str(exc) or type(exc).__name__
return MCPToolResult(
outputs=[Content.from_text(f"Error: {message}")],
is_error=True,
error_message=message,
)
except httpx.HTTPError as exc:
message = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
return MCPToolResult(
outputs=[Content.from_text(f"Error: {message}")],
is_error=True,
error_message=message,
)
except Exception as exc:
# Be defensive about MCP errors that may bubble up without being
# wrapped in ToolExecutionException by custom parsers.
try:
from mcp.shared.exceptions import McpError
except ImportError: # pragma: no cover - mcp is a hard dep but stay defensive
raise
if isinstance(exc, McpError):
message = str(exc) or type(exc).__name__
return MCPToolResult(
outputs=[Content.from_text(f"Error: {message}")],
is_error=True,
error_message=message,
)
raise
# Defensive normalisation: call_tool is typed ``str | list[Content]``.
# Default parser returns list, but custom parse_tool_results may return str.
if isinstance(raw, str):
outputs: list[Content] = [Content.from_text(raw)]
else:
outputs = list(raw)
return MCPToolResult(outputs=outputs)
@staticmethod
async def _invoke_list_tools(entry: _CacheEntry) -> MCPToolResult:
"""Handle the reserved :attr:`LIST_TOOLS_TOOL_NAME` invocation.
Pages through ``session.list_tools()`` (mirroring the pagination loop
in :meth:`agent_framework.MCPTool.load_tools`) and serialises the
full catalog as a single ``TextContent`` containing JSON of shape
``{"tools": [{name, description, inputSchema, outputSchema}, ...]}``.
The output shape, property names, and property order are stable so
downstream PowerFx expressions can rely on the schema. ``indent=2``
produces human-readable JSON for the conversation log;
``allow_nan=False`` guards against producing non-conformant JSON
``NaN``/``Infinity`` tokens if a misbehaving server returns such
values in a schema.
"""
from agent_framework import Content
session = getattr(entry.tool, "session", None)
if session is None:
message = "MCP session is not connected; cannot list tools."
return MCPToolResult(
outputs=[Content.from_text(f"Error: {message}")],
is_error=True,
error_message=message,
)
# Lazy import keeps ``mcp`` types out of module import time.
from mcp import types as mcp_types
collected: list[Any] = []
params: mcp_types.PaginatedRequestParams | None = None
while True:
tool_list = await session.list_tools(params=params)
collected.extend(tool_list.tools)
next_cursor = getattr(tool_list, "nextCursor", None)
if not next_cursor:
break
params = mcp_types.PaginatedRequestParams(cursor=next_cursor)
payload = {
"tools": [
{
"name": tool.name,
"description": tool.description,
"inputSchema": tool.inputSchema,
"outputSchema": tool.outputSchema,
}
for tool in collected
],
}
return MCPToolResult(outputs=[Content.from_text(json.dumps(payload, indent=2, allow_nan=False))])
async def aclose(self) -> None:
"""Close all cached MCP clients and the owned httpx clients.
Caller-supplied :class:`httpx.AsyncClient` instances (returned by the
``client_provider`` callback) are NOT closed.
Idempotent — a second call returns immediately. Drains any in-flight
``_create_entry`` tasks before returning so their resources are
cleaned up; the in-flight tasks see ``self._closed`` in phase 3 of
:meth:`_get_or_create_entry`, close their own entry, and resolve
their future with ``RuntimeError("DefaultMCPToolHandler is closed")``.
"""
async with self._cache_lock:
if self._closed:
return
self._closed = True
entries = list(self._cache.values())
self._cache.clear()
inflight_futures = list(self._inflight.values())
# Wait for in-flight creations to finish their self-cleanup. Each
# in-flight task self-closes its entry under the closed-flag branch
# in phase 3 and resolves its future with ``RuntimeError``; we
# swallow it here because the failure is expected at shutdown.
for fut in inflight_futures:
try:
await fut
except BaseException:
logger.debug("DefaultMCPToolHandler: in-flight future raised during aclose", exc_info=True)
continue
for entry in entries:
await self._close_entry(entry)
async def __aenter__(self) -> DefaultMCPToolHandler:
return self
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
await self.aclose()
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
async def _get_or_create_entry(self, invocation: MCPToolInvocation) -> _CacheEntry:
"""Look up (or create) the cached MCP client for this invocation."""
key = self._cache_key(
invocation.server_url,
invocation.server_label,
invocation.connection_name,
invocation.headers,
)
# Phase 1: check the cache and either claim creation or wait for an
# already in-flight creation.
creating = False
async with self._cache_lock:
if self._closed:
raise RuntimeError("DefaultMCPToolHandler is closed")
existing = self._cache.get(key)
if existing is not None:
self._cache.move_to_end(key)
return existing
inflight = self._inflight.get(key)
if inflight is None:
inflight = asyncio.get_running_loop().create_future()
self._inflight[key] = inflight
creating = True
if not creating:
return await inflight
# Phase 2: we own creation. Build the entry outside the lock.
try:
entry = await self._create_entry(invocation)
except BaseException as exc:
async with self._cache_lock:
self._inflight.pop(key, None)
if not inflight.done():
inflight.set_exception(exc if isinstance(exc, BaseException) else RuntimeError(str(exc)))
# Mark the exception retrieved to suppress noisy "Future exception
# was never retrieved" warnings when there are no other awaiters
# (other awaiters still see the exception through their ``await``).
inflight.exception()
raise
# Phase 3: insert with LRU eviction; resolve the in-flight future.
# If ``aclose`` ran while we were connecting, ``_closed`` is now
# True; don't insert into the cache (it has been drained), close
# the just-built entry, and surface the closed-handler error to
# all awaiters of the future.
evicted: _CacheEntry | None = None
duplicate: _CacheEntry | None = None
handler_closed = False
async with self._cache_lock:
self._inflight.pop(key, None)
if self._closed:
handler_closed = True
else:
existing = self._cache.get(key)
if existing is not None:
# Another writer beat us; prefer the existing entry and
# discard ours after the lock is released.
self._cache.move_to_end(key)
duplicate = entry
entry = existing
else:
self._cache[key] = entry
self._cache.move_to_end(key)
if len(self._cache) > self._cache_max_size:
_evicted_key, evicted = self._cache.popitem(last=False)
if not inflight.done():
inflight.set_result(entry)
if handler_closed:
# Close our orphaned entry; resolve the future with a clear
# error so the caller (and any other awaiters) surface a
# consistent "handler is closed" failure rather than receiving
# an entry we are about to close behind their back.
await self._close_entry(entry)
err = RuntimeError("DefaultMCPToolHandler is closed")
if not inflight.done():
inflight.set_exception(err)
inflight.exception()
raise err
if duplicate is not None:
await self._close_entry(duplicate)
if evicted is not None:
await self._close_entry(evicted)
return entry
async def _create_entry(self, invocation: MCPToolInvocation) -> _CacheEntry:
"""Construct (and connect) a fresh MCP client for ``invocation``."""
from agent_framework import MCPStreamableHTTPTool
provided_client: httpx.AsyncClient | None = None
if self._client_provider is not None:
provided_client = await self._client_provider(invocation)
# Capture headers for this cache entry so the header_provider closure
# always returns the same set, regardless of the runtime kwargs.
captured_headers = dict(invocation.headers)
def _header_provider(_kwargs: dict[str, Any]) -> dict[str, str]:
return captured_headers
tool: Any = MCPStreamableHTTPTool(
name=invocation.server_label or "McpClient",
url=invocation.server_url,
load_prompts=False,
http_client=provided_client,
header_provider=_header_provider if captured_headers else None,
)
try:
await tool.connect()
except BaseException:
try:
await tool.close()
except Exception: # pragma: no cover - best effort
logger.debug("DefaultMCPToolHandler: error closing tool after failed connect", exc_info=True)
raise
# ``MCPStreamableHTTPTool.get_mcp_client`` lazily creates an
# ``httpx.AsyncClient`` when no caller client was provided AND a
# ``header_provider`` was set. We treat any client allocated this
# way as owned (closed by the handler). When the caller supplies
# one, we never close it.
owned_client: httpx.AsyncClient | None = None
if provided_client is None:
owned_client = cast("httpx.AsyncClient | None", getattr(tool, "_httpx_client", None))
return _CacheEntry(tool=tool, owned_httpx_client=owned_client)
async def _close_entry(self, entry: _CacheEntry) -> None:
"""Close the MCP tool and any owned httpx client."""
try:
await entry.tool.close()
except Exception: # pragma: no cover - best effort
logger.debug("DefaultMCPToolHandler: error closing MCP tool", exc_info=True)
if entry.owned_httpx_client is not None:
try:
await entry.owned_httpx_client.aclose()
except Exception: # pragma: no cover - best effort
logger.debug("DefaultMCPToolHandler: error closing owned httpx client", exc_info=True)
@staticmethod
def _cache_key(
server_url: str,
server_label: str | None,
connection_name: str | None,
headers: dict[str, str] | None,
) -> tuple[str, str, str, str]:
"""Build an order-independent cache key for the invocation identity.
The key includes ``server_label`` and ``connection_name`` so that
callers using ``client_provider`` to dispatch on those fields
receive a fresh client per logical connection (matches the
documented dispatch contract).
Header *names* are lower-cased inside the hash payload only so
that ``Authorization`` and ``authorization`` map to the same
cache entry. Header values remain case-sensitive (per RFC 7235).
"""
if not headers:
headers_hash = "0"
else:
normalized = sorted((k.lower(), v) for k, v in headers.items())
payload = json.dumps(normalized, ensure_ascii=False)
headers_hash = hashlib.sha256(payload.encode("utf-8")).hexdigest()
return (server_url, server_label or "", connection_name or "", headers_hash)
@@ -0,0 +1,498 @@
# Copyright (c) Microsoft. All rights reserved.
"""Custom PowerFx-like functions for declarative workflows.
This module provides Python implementations of custom PowerFx functions
that are used in declarative workflows but may not be available in the
standard PowerFx Python package.
These functions can be used as fallbacks when PowerFx is not available,
or registered with the PowerFx engine when it is available.
"""
from __future__ import annotations
from typing import Any, cast
def message_text(messages: Any) -> str:
"""Extract text content from a message or list of messages.
This is equivalent to the .NET MessageText() function.
Args:
messages: A message object, list of messages, or string
Returns:
The concatenated text content of all messages
Examples:
.. code-block:: python
message_text([{"role": "assistant", "content": "Hello"}])
# Returns: 'Hello'
"""
if messages is None:
return ""
if isinstance(messages, str):
return messages
if isinstance(messages, dict):
# Single message object
messages_dict = cast(dict[str, Any], messages)
content: Any = messages_dict.get("content", "")
if isinstance(content, str):
return content
text_attr = getattr(content, "text", None)
if text_attr is not None:
return str(text_attr)
return str(content) if content else ""
if isinstance(messages, list):
# List of messages - concatenate all text
texts: list[str] = []
message_list = cast(list[Any], messages)
for msg in message_list:
if isinstance(msg, str):
texts.append(msg)
elif isinstance(msg, dict):
msg_dict = cast(dict[str, Any], msg)
msg_content: Any = msg_dict.get("content", "")
if isinstance(msg_content, str):
texts.append(msg_content)
elif msg_content:
texts.append(str(msg_content))
else:
msg_obj: object = msg
if hasattr(msg_obj, "content"):
msg_obj_content: Any = getattr(msg_obj, "content", None)
if isinstance(msg_obj_content, str):
texts.append(msg_obj_content)
elif (msg_obj_text := getattr(msg_obj_content, "text", None)) is not None:
texts.append(str(msg_obj_text))
elif msg_obj_content:
texts.append(str(msg_obj_content))
return " ".join(texts)
# Try to get text attribute
if hasattr(messages, "text"):
return str(messages.text)
if hasattr(messages, "content"):
content_attr: Any = messages.content
if isinstance(content_attr, str):
return content_attr
return str(content_attr) if content_attr else ""
return str(messages) if messages else ""
def user_message(text: str) -> dict[str, str]:
"""Create a user message object.
This is equivalent to the .NET UserMessage() function.
Args:
text: The text content of the message
Returns:
A message dictionary with role 'user'
Examples:
.. code-block:: python
user_message("Hello")
# Returns: {'role': 'user', 'content': 'Hello'}
"""
return {"role": "user", "content": str(text) if text else ""}
def assistant_message(text: str) -> dict[str, str]:
"""Create an assistant message object.
Args:
text: The text content of the message
Returns:
A message dictionary with role 'assistant'
Examples:
.. code-block:: python
assistant_message("Hello")
# Returns: {'role': 'assistant', 'content': 'Hello'}
"""
return {"role": "assistant", "content": str(text) if text else ""}
def agent_message(text: str) -> dict[str, str]:
"""Create an agent/assistant message object.
This is equivalent to the .NET AgentMessage() function.
It's an alias for assistant_message() for .NET compatibility.
Args:
text: The text content of the message
Returns:
A message dictionary with role 'assistant'
Examples:
.. code-block:: python
agent_message("Hello")
# Returns: {'role': 'assistant', 'content': 'Hello'}
"""
return {"role": "assistant", "content": str(text) if text else ""}
def system_message(text: str) -> dict[str, str]:
"""Create a system message object.
Args:
text: The text content of the message
Returns:
A message dictionary with role 'system'
Examples:
.. code-block:: python
system_message("You are a helpful assistant")
# Returns: {'role': 'system', 'content': 'You are a helpful assistant'}
"""
return {"role": "system", "content": str(text) if text else ""}
def if_func(condition: Any, true_value: Any, false_value: Any = None) -> Any:
"""Conditional expression - returns one value or another based on a condition.
This is equivalent to the PowerFx If() function.
Args:
condition: The condition to evaluate (truthy/falsy)
true_value: Value to return if condition is truthy
false_value: Value to return if condition is falsy (defaults to None)
Returns:
true_value if condition is truthy, otherwise false_value
"""
return true_value if condition else false_value
def is_blank(value: Any) -> bool:
"""Check if a value is blank (None, empty string, empty list, etc.).
This is equivalent to the PowerFx IsBlank() function.
Args:
value: The value to check
Returns:
True if the value is considered blank
"""
if value is None:
return True
if isinstance(value, str) and not value.strip():
return True
if isinstance(value, (list, dict)):
return len(value) == 0 # type: ignore[reportUnknownArgumentType]
return False
def or_func(*args: Any) -> bool:
"""Logical OR - returns True if any argument is truthy.
This is equivalent to the PowerFx Or() function.
Args:
*args: Variable number of values to check
Returns:
True if any argument is truthy
"""
return any(bool(arg) for arg in args)
def and_func(*args: Any) -> bool:
"""Logical AND - returns True if all arguments are truthy.
This is equivalent to the PowerFx And() function.
Args:
*args: Variable number of values to check
Returns:
True if all arguments are truthy
"""
return all(bool(arg) for arg in args)
def not_func(value: Any) -> bool:
"""Logical NOT - returns the opposite boolean value.
This is equivalent to the PowerFx Not() function.
Args:
value: The value to negate
Returns:
True if value is falsy, False if truthy
"""
return not bool(value)
def count_rows(table: Any) -> int:
"""Count the number of rows/items in a table/list.
This is equivalent to the PowerFx CountRows() function.
Args:
table: A list or table-like object
Returns:
The number of rows/items
"""
if table is None:
return 0
if isinstance(table, (list, tuple)):
return len(cast(list[Any], table))
if isinstance(table, dict):
return len(cast(dict[str, Any], table))
return 0
def first(table: Any) -> Any:
"""Get the first item from a table/list.
This is equivalent to the PowerFx First() function.
Args:
table: A list or table-like object
Returns:
The first item, or None if empty
"""
if table is None:
return None
if isinstance(table, (list, tuple)):
table_list = cast(list[Any], table)
if len(table_list) > 0:
return table_list[0]
return None
def last(table: Any) -> Any:
"""Get the last item from a table/list.
This is equivalent to the PowerFx Last() function.
Args:
table: A list or table-like object
Returns:
The last item, or None if empty
"""
if table is None:
return None
if isinstance(table, (list, tuple)):
table_list = cast(list[Any], table)
if len(table_list) > 0:
return table_list[-1]
return None
def find(substring: str | None, text: str | None) -> int | None:
"""Find the position of a substring within text.
This is equivalent to the PowerFx Find() function.
Returns None (Blank) if not found, otherwise 1-based index.
Args:
substring: The substring to find
text: The text to search in
Returns:
1-based index if found, None (Blank) if not found
"""
if substring is None or text is None:
return None
try:
index = str(text).find(str(substring))
return index + 1 if index >= 0 else None
except (TypeError, ValueError):
return None
def upper(text: str | None) -> str:
"""Convert text to uppercase.
This is equivalent to the PowerFx Upper() function.
Args:
text: The text to convert
Returns:
Uppercase text
"""
if text is None:
return ""
return str(text).upper()
def lower(text: str | None) -> str:
"""Convert text to lowercase.
This is equivalent to the PowerFx Lower() function.
Args:
text: The text to convert
Returns:
Lowercase text
"""
if text is None:
return ""
return str(text).lower()
def concat_strings(*args: Any) -> str:
"""Concatenate multiple string arguments.
This is equivalent to the PowerFx Concat() function for string concatenation.
Args:
*args: Variable number of values to concatenate
Returns:
Concatenated string
"""
return "".join(str(arg) if arg is not None else "" for arg in args)
def concat_text(table: Any, field: str | None = None, separator: str = "") -> str:
"""Concatenate values from a table/list.
This is equivalent to the PowerFx Concat() function.
Args:
table: A list of items
field: Optional field name to extract from each item
separator: Separator between values
Returns:
Concatenated string
"""
if table is None:
return ""
if not isinstance(table, (list, tuple)):
return str(table)
values: list[str] = []
for item in cast(list[Any], table):
value: Any = None
if field and isinstance(item, dict):
item_dict = cast(dict[str, Any], item)
value = item_dict.get(field, "")
elif field and hasattr(item, field):
value = getattr(item, field, "")
else:
value = item
values.append(str(value) if value is not None else "")
return separator.join(values)
def for_all(table: Any, expression: str, field_mapping: dict[str, str] | None = None) -> list[Any]:
"""Apply an expression to each row of a table.
This is equivalent to the PowerFx ForAll() function.
Args:
table: A list of records
expression: A string expression that references item fields
field_mapping: Optional dict mapping placeholder names to field names
Returns:
List of results from applying expression to each row
Note:
The expression can use field names directly from the record.
For example: ForAll(items, "$" & name & ": " & description)
"""
if table is None or not isinstance(table, (list, tuple)):
return []
results: list[Any] = []
for item in cast(list[Any], table):
# If item is a dict, we can directly substitute field values
if isinstance(item, dict):
item_dict = cast(dict[str, Any], item)
# The expression is typically already evaluated by the expression parser
# This function primarily handles table iteration
# Return the item itself for further processing
results.append(item_dict)
else:
results.append(item)
return results
def search_table(table: Any, value: Any, column: str) -> list[Any]:
"""Search for rows in a table where a column matches a value.
This is equivalent to the PowerFx Search() function.
Args:
table: A list of records
value: The value to search for
column: The column name to search in
Returns:
List of matching records
"""
if table is None or not isinstance(table, (list, tuple)):
return []
results: list[Any] = []
search_value = str(value).lower() if value else ""
for item in cast(list[Any], table):
item_value: Any = None
if isinstance(item, dict):
item_dict = cast(dict[str, Any], item)
item_value = item_dict.get(column, "")
elif hasattr(item, column):
item_value = getattr(item, column, "")
else:
continue
# Case-insensitive contains search
if search_value in str(item_value).lower():
results.append(item)
return results
# Registry of custom functions
CUSTOM_FUNCTIONS: dict[str, Any] = {
"MessageText": message_text,
"UserMessage": user_message,
"AssistantMessage": assistant_message,
"AgentMessage": agent_message, # .NET compatibility alias for AssistantMessage
"SystemMessage": system_message,
"If": if_func,
"IsBlank": is_blank,
"Or": or_func,
"And": and_func,
"Not": not_func,
"CountRows": count_rows,
"First": first,
"Last": last,
"Find": find,
"Upper": upper,
"Lower": lower,
"Concat": concat_strings,
"Search": search_table,
"ForAll": for_all,
}
@@ -0,0 +1,650 @@
# Copyright (c) Microsoft. All rights reserved.
"""WorkflowState manages PowerFx variables during declarative workflow execution.
This module provides state management for declarative workflows, handling:
- Workflow inputs (read-only)
- Turn-scoped variables
- Workflow outputs
- Agent results and context
"""
from __future__ import annotations
import logging
import uuid
from collections.abc import Mapping
from typing import Any, cast
try:
from powerfx import Engine
_powerfx_engine: Engine | None = Engine()
except (ImportError, RuntimeError):
# ImportError: powerfx package not installed
# RuntimeError: .NET runtime not available or misconfigured
_powerfx_engine = None
logger = logging.getLogger("agent_framework.declarative")
class WorkflowState:
"""Manages variables and state during declarative workflow execution.
WorkflowState provides a unified interface for:
- Reading workflow inputs (immutable after initialization)
- Managing Local-scoped variables that persist across actions
- Storing agent results and making them available to subsequent actions
- Evaluating PowerFx expressions with the current state as context
The state is organized into namespaces that mirror the .NET implementation:
- Workflow.Inputs: Initial inputs to the workflow
- Workflow.Outputs: Values to be returned from the workflow
- Local: Variables that persist within the current workflow turn
- System: System-level variables (ConversationId, LastMessage, etc.)
- Agent: Results from the most recent agent invocation
- Conversation: Conversation history and messages
Examples:
.. code-block:: python
from agent_framework_declarative import WorkflowState
# Initialize with inputs
state = WorkflowState(inputs={"query": "Hello", "user_id": "123"})
# Access inputs (read-only)
query = state.get("Workflow.Inputs.query") # "Hello"
# Set Local-scoped variables
state.set("Local.results", [])
state.append("Local.results", "item1")
state.append("Local.results", "item2")
# Set workflow outputs
state.set("Workflow.Outputs.response", "Completed")
.. code-block:: python
from agent_framework_declarative import WorkflowState
# PowerFx expression evaluation
state = WorkflowState(inputs={"name": "World"})
result = state.eval("=Concat('Hello ', Workflow.Inputs.name)")
# result: "Hello World"
# Non-PowerFx strings are returned as-is
plain = state.eval("Hello World")
# plain: "Hello World"
.. code-block:: python
from agent_framework_declarative import WorkflowState
# Working with agent results
state = WorkflowState()
state.set_agent_result(
text="The answer is 42.",
messages=[],
tool_calls=[],
)
# Access agent result in subsequent actions
response = state.get("Agent.text") # "The answer is 42."
"""
def __init__(
self,
inputs: Mapping[str, Any] | None = None,
) -> None:
"""Initialize workflow state with optional inputs.
Args:
inputs: Initial inputs to the workflow. These become available
as Workflow.Inputs.* and are immutable after initialization.
"""
self._inputs: dict[str, Any] = dict(inputs) if inputs else {}
self._local: dict[str, Any] = {}
self._outputs: dict[str, Any] = {}
conversation_id = str(uuid.uuid4())
self._system: dict[str, Any] = {
"ConversationId": conversation_id,
"LastMessage": {"Text": "", "Id": ""},
"LastMessageText": "",
"LastMessageId": "",
"conversations": {
conversation_id: {"id": conversation_id, "messages": []},
},
}
self._agent: dict[str, Any] = {}
self._conversation: dict[str, Any] = {
"messages": [],
"history": [],
}
self._custom: dict[str, Any] = {}
@property
def inputs(self) -> Mapping[str, Any]:
"""Get the workflow inputs (read-only)."""
return self._inputs
@property
def outputs(self) -> dict[str, Any]:
"""Get the workflow outputs."""
return self._outputs
@property
def local(self) -> dict[str, Any]:
"""Get the Local-scoped variables."""
return self._local
@property
def system(self) -> dict[str, Any]:
"""Get the System-scoped variables."""
return self._system
@property
def agent(self) -> dict[str, Any]:
"""Get the most recent agent result."""
return self._agent
@property
def conversation(self) -> dict[str, Any]:
"""Get the conversation state."""
return self._conversation
def get(self, path: str, default: Any = None) -> Any:
"""Get a value from the state using a dot-notated path.
Args:
path: Dot-notated path like 'Local.results' or 'Workflow.Inputs.query'
default: Default value if path doesn't exist
Returns:
The value at the path, or default if not found
"""
parts = path.split(".")
if not parts:
return default
namespace = parts[0]
remaining = parts[1:]
# Handle Workflow.Inputs and Workflow.Outputs specially
if namespace == "Workflow" and remaining:
sub_namespace = remaining[0]
remaining = remaining[1:]
if sub_namespace == "Inputs":
obj: Any = self._inputs
elif sub_namespace == "Outputs":
obj = self._outputs
else:
return default
elif namespace == "Local":
obj = self._local
elif namespace == "System":
obj = self._system
elif namespace == "Agent":
obj = self._agent
elif namespace == "Conversation":
obj = self._conversation
else:
# Try custom namespace
obj = self._custom.get(namespace, default)
if obj is default:
return default
# Navigate the remaining path
for part in remaining:
if isinstance(obj, dict):
obj_dict: dict[str, Any] = cast(dict[str, Any], obj)
obj = obj_dict.get(part, default)
if obj is default:
return default
elif hasattr(obj, part):
obj = getattr(obj, part)
else:
return default
return obj
def set(self, path: str, value: Any) -> None:
"""Set a value in the state using a dot-notated path.
Args:
path: Dot-notated path like 'Local.results' or 'Workflow.Outputs.response'
value: The value to set
Raises:
ValueError: If attempting to set Workflow.Inputs (which is read-only)
"""
parts = path.split(".")
if not parts:
return
namespace = parts[0]
remaining = parts[1:]
# Handle Workflow.Inputs and Workflow.Outputs specially
if namespace == "Workflow":
if not remaining:
raise ValueError("Cannot set 'Workflow' directly; use 'Workflow.Outputs.*'")
sub_namespace = remaining[0]
remaining = remaining[1:]
if sub_namespace == "Inputs":
raise ValueError("Cannot modify Workflow.Inputs - they are read-only")
if sub_namespace == "Outputs":
target = self._outputs
else:
raise ValueError(f"Unknown Workflow namespace: {sub_namespace}")
elif namespace == "Local":
target = self._local
elif namespace == "System":
target = self._system
elif namespace == "Agent":
target = self._agent
elif namespace == "Conversation":
target = self._conversation
else:
# Create or use custom namespace
if namespace not in self._custom:
self._custom[namespace] = {}
target = self._custom[namespace]
# Navigate to the parent and set the value
if not remaining:
# Setting the namespace root itself - this shouldn't happen normally
raise ValueError(f"Cannot replace entire namespace '{namespace}'")
# Navigate to parent, creating dicts as needed
for part in remaining[:-1]:
if part not in target:
target[part] = {}
target = target[part]
# Set the final value
target[remaining[-1]] = value
def append(self, path: str, value: Any) -> None:
"""Append a value to a list at the specified path.
If the path doesn't exist, creates a new list with the value.
If the path exists but isn't a list, raises ValueError.
Args:
path: Dot-notated path to a list
value: The value to append
Raises:
ValueError: If the existing value is not a list
"""
existing = self.get(path)
if existing is None:
self.set(path, [value])
elif isinstance(existing, list):
existing_list = cast(list[Any], existing)
existing_list.append(value)
self.set(path, existing_list)
else:
raise ValueError(f"Cannot append to non-list at path '{path}'")
def set_agent_result(
self,
text: str | None = None,
messages: list[Any] | None = None,
tool_calls: list[Any] | None = None,
**kwargs: Any,
) -> None:
"""Set the result from the most recent agent invocation.
This updates the 'agent' namespace with the agent's response,
making it available to subsequent actions via agent.text, agent.messages, etc.
Args:
text: The text content of the agent's response
messages: The messages from the agent
tool_calls: Any tool calls made by the agent
**kwargs: Additional result data
"""
self._agent = {
"text": text,
"messages": messages or [],
"toolCalls": tool_calls or [],
**kwargs,
}
def add_conversation_message(self, message: Any) -> None:
"""Add a message to the conversation history.
Args:
message: The message to add (typically a Message or similar)
"""
self._conversation["messages"].append(message)
self._conversation["history"].append(message)
def to_powerfx_symbols(self) -> dict[str, Any]:
"""Convert the current state to a PowerFx symbols dictionary.
Returns:
A dictionary suitable for passing to PowerFx Engine.eval()
"""
symbols = {
"Workflow": {
"Inputs": dict(self._inputs),
"Outputs": dict(self._outputs),
},
"Local": dict(self._local),
"System": dict(self._system),
"Agent": dict(self._agent),
"Conversation": dict(self._conversation),
# Also expose inputs at top level for backward compatibility with =inputs.X syntax
"inputs": dict(self._inputs),
**self._custom,
}
# Debug log the Local symbols to help diagnose type issues
if self._local:
for key, value in self._local.items():
logger.debug(
f"PowerFx symbol Local.{key}: type={type(value).__name__}, "
f"value_preview={str(value)[:100] if value else None}"
)
return symbols
def eval(self, expression: str) -> Any:
"""Evaluate a PowerFx expression with the current state.
Expressions starting with '=' are evaluated as PowerFx.
Other strings are returned as-is (after variable interpolation if applicable).
Args:
expression: The expression to evaluate
Returns:
The evaluated result, or the original expression if not a PowerFx expression
"""
if not expression:
return expression
if not expression.startswith("="):
return expression
# Strip the leading '=' for evaluation
formula = expression[1:]
if _powerfx_engine is not None:
# Try PowerFx evaluation first
try:
symbols = self.to_powerfx_symbols()
return _powerfx_engine.eval(formula, symbols=symbols)
except Exception as exc:
logger.warning(f"PowerFx evaluation failed for '{expression[:50]}': {exc}")
# Fall through to simple evaluation
# Fallback: Simple expression evaluation using custom functions
return self._eval_simple(formula)
def _eval_simple(self, formula: str) -> Any:
"""Simple expression evaluation when PowerFx is not available.
Supports:
- Variable references: Local.X, System.X, Workflow.Inputs.X
- Simple function calls: IsBlank(x), Find(a, b), etc.
- Simple comparisons: x < 4, x = "value"
- Logical operators: And, Or, Not, ||, !
- Negation: !expression
Args:
formula: The formula to evaluate (without leading '=')
Returns:
The evaluated result
"""
from ._powerfx_functions import CUSTOM_FUNCTIONS
formula = formula.strip()
# Handle negation prefix
if formula.startswith("!"):
inner = formula[1:].strip()
result = self._eval_simple(inner)
return not bool(result)
# Handle Not() function
if formula.startswith("Not(") and formula.endswith(")"):
inner = formula[4:-1].strip()
result = self._eval_simple(inner)
return not bool(result)
# Handle function calls
for func_name, func in CUSTOM_FUNCTIONS.items():
if formula.startswith(f"{func_name}(") and formula.endswith(")"):
args_str = formula[len(func_name) + 1 : -1]
# Simple argument parsing (doesn't handle nested calls well)
args = self._parse_function_args(args_str)
evaluated_args = [self._eval_simple(arg) if isinstance(arg, str) else arg for arg in args]
try:
return func(*evaluated_args)
except Exception as e:
logger.warning(f"Function {func_name} failed: {e}")
return formula
# Handle And operator
if " And " in formula:
parts = formula.split(" And ", 1)
left = self._eval_simple(parts[0])
right = self._eval_simple(parts[1])
return bool(left) and bool(right)
# Handle Or operator (||)
if " || " in formula or " Or " in formula:
parts = formula.split(" || ", 1) if " || " in formula else formula.split(" Or ", 1)
left = self._eval_simple(parts[0])
right = self._eval_simple(parts[1])
return bool(left) or bool(right)
# Handle comparison operators
for op in [" < ", " > ", " <= ", " >= ", " <> ", " = "]:
if op in formula:
parts = formula.split(op, 1)
left = self._eval_simple(parts[0].strip())
right = self._eval_simple(parts[1].strip())
if op == " < ":
return left < right
if op == " > ":
return left > right
if op == " <= ":
return left <= right
if op == " >= ":
return left >= right
if op == " <> ":
return left != right
if op == " = ":
return left == right
# Handle arithmetic operators
if " + " in formula:
parts = formula.split(" + ", 1)
left = self._eval_simple(parts[0].strip())
right = self._eval_simple(parts[1].strip())
# Treat None as 0 for arithmetic (PowerFx behavior)
if left is None:
left = 0
if right is None:
right = 0
# Try numeric addition first, fall back to string concat
try:
return float(left) + float(right)
except (ValueError, TypeError):
return str(left) + str(right)
if " - " in formula:
parts = formula.split(" - ", 1)
left = self._eval_simple(parts[0].strip())
right = self._eval_simple(parts[1].strip())
# Treat None as 0 for arithmetic (PowerFx behavior)
if left is None:
left = 0
if right is None:
right = 0
try:
return float(left) - float(right)
except (ValueError, TypeError):
return formula
# Handle multiplication
if " * " in formula:
parts = formula.split(" * ", 1)
left = self._eval_simple(parts[0].strip())
right = self._eval_simple(parts[1].strip())
# Treat None as 0 for arithmetic (PowerFx behavior)
if left is None:
left = 0
if right is None:
right = 0
try:
return float(left) * float(right)
except (ValueError, TypeError):
return formula
# Handle division with div-by-zero protection
if " / " in formula:
parts = formula.split(" / ", 1)
left = self._eval_simple(parts[0].strip())
right = self._eval_simple(parts[1].strip())
# Treat None as 0 for arithmetic (PowerFx behavior)
if left is None:
left = 0
if right is None:
right = 0
try:
right_float = float(right)
if right_float == 0:
# PowerFx returns Error for division by zero; we return None (Blank)
logger.warning(f"Division by zero in expression: {formula}")
return None
return float(left) / right_float
except (ValueError, TypeError):
return formula
# Handle string literals
if (formula.startswith('"') and formula.endswith('"')) or (formula.startswith("'") and formula.endswith("'")):
return formula[1:-1]
# Handle numeric literals
try:
if "." in formula:
return float(formula)
return int(formula)
except ValueError:
pass
# Handle boolean literals
if formula.lower() == "true":
return True
if formula.lower() == "false":
return False
# Handle variable references
if "." in formula:
# For known namespaces, return None if not found (PowerFx semantics)
# rather than the formula string
if formula.startswith(("Local.", "Workflow.", "Agent.", "Conversation.", "System.")):
return self.get(formula)
not_found = object()
value = self.get(formula, default=not_found)
if value is not not_found:
return value
# Return the formula as-is if we can't evaluate it
return formula
def _parse_function_args(self, args_str: str) -> list[str]:
"""Parse function arguments, handling nested parentheses and strings.
Args:
args_str: The argument string (without outer parentheses)
Returns:
List of argument strings
"""
args: list[str] = []
current = ""
depth = 0
in_string = False
string_char = None
for char in args_str:
if char in ('"', "'") and not in_string:
in_string = True
string_char = char
current += char
elif char == string_char and in_string:
in_string = False
string_char = None
current += char
elif char == "(" and not in_string:
depth += 1
current += char
elif char == ")" and not in_string:
depth -= 1
current += char
elif char == "," and depth == 0 and not in_string:
args.append(current.strip())
current = ""
else:
current += char
if current.strip():
args.append(current.strip())
return args
def eval_if_expression(self, value: Any) -> Any:
"""Evaluate a value if it's a PowerFx expression, otherwise return as-is.
This is a convenience method that handles both expressions and literals.
Args:
value: A value that may or may not be a PowerFx expression
Returns:
The evaluated result if it's an expression, or the original value
"""
if isinstance(value, str):
return self.eval(value)
if isinstance(value, dict):
return {str(k): self.eval_if_expression(v) for k, v in value.items()} # type: ignore[reportUnknownVariableType]
if isinstance(value, list):
return [self.eval_if_expression(item) for item in value] # type: ignore[reportUnknownVariableType]
return value
def reset_local(self) -> None:
"""Reset Local-scoped variables for a new turn.
This clears the Local namespace while preserving other state.
"""
self._local.clear()
def reset_agent(self) -> None:
"""Reset the agent result for a new agent invocation."""
self._agent.clear()
def clone(self) -> WorkflowState:
"""Create a shallow copy of the state.
Returns:
A new WorkflowState with copied data
"""
import copy
new_state = WorkflowState()
new_state._inputs = copy.copy(self._inputs)
new_state._local = copy.copy(self._local)
new_state._system = copy.copy(self._system)
new_state._outputs = copy.copy(self._outputs)
new_state._agent = copy.copy(self._agent)
new_state._conversation = copy.copy(self._conversation)
new_state._custom = copy.copy(self._custom)
return new_state